deimos-ruby 2.6.0.pre.beta1 → 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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -2
  3. data/README.md +45 -20
  4. data/deimos-ruby.gemspec +0 -1
  5. data/docs/CONFIGURATION.md +5 -11
  6. data/lib/deimos/active_record_consume/batch_consumption.rb +128 -31
  7. data/lib/deimos/active_record_consume/batch_record.rb +3 -0
  8. data/lib/deimos/active_record_consume/message_consumption.rb +2 -2
  9. data/lib/deimos/active_record_consumer.rb +6 -1
  10. data/lib/deimos/active_record_producer.rb +1 -1
  11. data/lib/deimos/config/configuration.rb +11 -34
  12. data/lib/deimos/exceptions.rb +17 -0
  13. data/lib/deimos/ext/consumer_route.rb +3 -1
  14. data/lib/deimos/ext/producer_middleware.rb +2 -2
  15. data/lib/deimos/logging.rb +0 -9
  16. data/lib/deimos/producer.rb +5 -5
  17. data/lib/deimos/schema_backends/avro_base.rb +36 -7
  18. data/lib/deimos/schema_class/base.rb +62 -0
  19. data/lib/deimos/schema_class/enum.rb +49 -0
  20. data/lib/deimos/schema_class/record.rb +100 -0
  21. data/lib/deimos/transcoder.rb +6 -6
  22. data/lib/deimos/utils/deadlock_retry.rb +16 -2
  23. data/lib/deimos/utils/schema_class.rb +47 -14
  24. data/lib/deimos/version.rb +1 -1
  25. data/lib/deimos.rb +4 -3
  26. data/lib/generators/deimos/schema_class/templates/schema_class.rb.tt +8 -0
  27. data/lib/generators/deimos/schema_class/templates/schema_enum.rb.tt +13 -0
  28. data/lib/generators/deimos/schema_class/templates/schema_record.rb.tt +102 -0
  29. data/lib/generators/deimos/schema_class_generator.rb +369 -0
  30. data/lib/tasks/deimos.rake +4 -16
  31. data/regenerate_test_schema_classes.rb +3 -12
  32. data/spec/active_record_batch_consumer_association_spec.rb +1 -1
  33. data/spec/active_record_batch_consumer_spec.rb +286 -1
  34. data/spec/active_record_consume/batch_consumption_spec.rb +1 -1
  35. data/spec/active_record_consumer_spec.rb +3 -3
  36. data/spec/active_record_producer_spec.rb +1 -1
  37. data/spec/batch_consumer_spec.rb +2 -2
  38. data/spec/consumer_spec.rb +5 -5
  39. data/spec/generators/schema_class/my_schema_spec.rb +16 -0
  40. data/spec/generators/schema_class/my_schema_with_circular_reference_spec.rb +98 -0
  41. data/spec/generators/schema_class/my_schema_with_complex_types_spec.rb +237 -0
  42. data/spec/generators/schema_class_generator_spec.rb +283 -0
  43. data/spec/schemas/my_namespace/generated.rb +4 -4
  44. data/spec/schemas/my_namespace/my_long_namespace_schema.rb +2 -2
  45. data/spec/schemas/my_namespace/my_nested_schema.rb +3 -3
  46. data/spec/schemas/my_namespace/my_schema.rb +2 -2
  47. data/spec/schemas/my_namespace/my_schema_compound_key.rb +2 -2
  48. data/spec/schemas/my_namespace/my_schema_id_key.rb +2 -2
  49. data/spec/schemas/my_namespace/my_schema_key.rb +2 -2
  50. data/spec/schemas/my_namespace/my_schema_with_boolean.rb +2 -2
  51. data/spec/schemas/my_namespace/my_schema_with_circular_reference.rb +3 -3
  52. data/spec/schemas/my_namespace/my_schema_with_complex_type.rb +6 -6
  53. data/spec/schemas/my_namespace/my_schema_with_date_time.rb +2 -2
  54. data/spec/schemas/my_namespace/my_schema_with_id.rb +2 -2
  55. data/spec/schemas/my_namespace/my_schema_with_title.rb +2 -2
  56. data/spec/schemas/my_namespace/my_schema_with_union_type.rb +6 -6
  57. data/spec/schemas/my_namespace/my_schema_with_unique_id.rb +2 -2
  58. data/spec/schemas/my_namespace/my_updated_schema.rb +1 -1
  59. data/spec/schemas/my_namespace/request/create_topic.rb +2 -2
  60. data/spec/schemas/my_namespace/request/index.rb +2 -2
  61. data/spec/schemas/my_namespace/request/update_request.rb +2 -2
  62. data/spec/schemas/my_namespace/response/create_topic.rb +2 -2
  63. data/spec/schemas/my_namespace/response/index.rb +2 -2
  64. data/spec/schemas/my_namespace/response/update_response.rb +2 -2
  65. data/spec/schemas/my_namespace/wibble.rb +2 -2
  66. data/spec/schemas/my_namespace/widget.rb +2 -2
  67. data/spec/schemas/my_namespace/widget_the_second.rb +2 -2
  68. data/spec/schemas/my_namespace/widget_the_third.rb +2 -2
  69. data/spec/snapshots/consumers-no-nest.snap +1740 -0
  70. data/spec/snapshots/consumers.snap +1720 -0
  71. data/spec/snapshots/consumers_and_producers-no-nest.snap +1740 -0
  72. data/spec/snapshots/consumers_and_producers.snap +1720 -0
  73. data/spec/snapshots/consumers_circular-no-nest.snap +1740 -0
  74. data/spec/snapshots/consumers_circular.snap +1720 -0
  75. data/spec/snapshots/consumers_complex_types-no-nest.snap +1740 -0
  76. data/spec/snapshots/consumers_complex_types.snap +1720 -0
  77. data/spec/snapshots/consumers_nested-no-nest.snap +1740 -0
  78. data/spec/snapshots/consumers_nested.snap +1720 -0
  79. data/spec/snapshots/namespace_folders.snap +1800 -0
  80. data/spec/snapshots/namespace_map.snap +1800 -0
  81. data/spec/snapshots/producers_with_key-no-nest.snap +1740 -0
  82. data/spec/snapshots/producers_with_key.snap +1720 -0
  83. data/spec/spec_helper.rb +2 -2
  84. data/spec/utils/db_poller_spec.rb +1 -1
  85. data/spec/utils/deadlock_retry_spec.rb +57 -0
  86. metadata +27 -18
  87. data/lib/deimos/schema_class.rb +0 -72
  88. data/spec/schema_class_spec.rb +0 -65
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ba96f4ae17cd71ddcda0499103bfaedc88d2757e5e6eb6034b3cffb9e9898034
4
- data.tar.gz: f707d6ae52803d9e2cdca87b03c409ba5f47bc1ceaad243fb80b8bd91a2b4dff
3
+ metadata.gz: 3399390b0e3dc85c67c95c4d633d3ce65efb80258848e73c6b0fa5d9244db13e
4
+ data.tar.gz: d236d19cd353f581121dbf561cba34ff78a192bd636ac992fcb1ad87b5913375
5
5
  SHA512:
6
- metadata.gz: 6151699039457db7eecc81f33bdae4ab921f68220ba75db0b6ce935555a112903b280f942c35cd459bb298128cc4e3f4240f26c6538bbe94b0c0924ad0bd8958
7
- data.tar.gz: d1882baeffc9e69998fd704559b7c21f83e966e365c4e96b60f073412b40c2b242a8749e0e9890379cdfc88d96e2687dcf5d9293015f3e8ee15608c78c5bc3fb
6
+ metadata.gz: 502f900a5d5220c55d13bbc50ab329c826d3da953b14adfbaaaf1668e229d2ede46268729ed3c7bc631045feb6927fe85126a6b990f28b4f00630efdb0bb5501
7
+ data.tar.gz: 44d082485bfb68be5b2c49f228ab29626eb5c7b80b98873284a7844fba55c881ac805d0ce9db2c9d2f44f8b68eb781f541a5d1d0099b94f2f7ee236a29197093
data/CHANGELOG.md CHANGED
@@ -5,9 +5,15 @@ 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
9
 
10
- - Major change: Extract Avro schema class generation out of Deimos and into `avro-gen-ruby`.
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.
13
+
14
+ ## 2.5.4 - 2026-07-15
15
+
16
+ - Fix: `DeadlockRetry.wrap` coerces its `tags` argument with `Array(...)`, so a scalar topic String (passed by `MassUpdater`/batch consumption) no longer raises `NoMethodError` on `String#to_a` inside the rescue, which had defeated the deadlock retry on the first deadlock.
11
17
 
12
18
  ## 2.5.3 - 2026-04-28
13
19
 
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
@@ -840,23 +876,19 @@ DB backend only when your rake task is running.
840
876
 
841
877
  # Generated Schema Classes
842
878
 
843
- Deimos generates classes from Avro schemas using the
844
- [avro-gen-ruby](https://github.com/flipp-oss/avro-gen-ruby) gem (namespace `AvroGen`).
845
- These classes are documented with YARD to aid in IDE auto-complete, and will help to move
846
- errors closer to the code.
879
+ Deimos offers a way to generate classes from Avro schemas. These classes are documented
880
+ with YARD to aid in IDE auto-complete, and will help to move errors closer to the code.
847
881
 
848
- Add the following configurations for schema class generation:
882
+ Add the following configurations for schema class generation:
849
883
 
850
884
  ```ruby
851
- config.avrogen.generated_class_path 'path/to/generated/classes' # Defaults to 'app/lib/schema_classes'
885
+ config.schema.generated_class_path 'path/to/generated/classes' # Defaults to 'app/lib/schema_classes'
852
886
  ```
853
887
 
854
888
  Run the following command to generate schema classes in your application. It will generate classes for every configured consumer or producer by `Deimos.configure`:
855
889
 
856
890
  bundle exec rake deimos:generate_schema_classes
857
891
 
858
- (The standalone `bundle exec rake avro:generate` task generates classes for every schema on disk, without the Kafka-aware key/tombstone handling.)
859
-
860
892
  Add the following configurations to start using generated schema classes in your application's Consumers and Producers:
861
893
 
862
894
  config.schema.use_schema_classes true
@@ -868,14 +900,7 @@ Note that if you have a schema in your repo but have not configured a producer o
868
900
 
869
901
  One additional configuration option indicates whether nested records should be generated as top-level classes or would remain nested inside the generated class for its parent schema. The default is to nest them, as a flattened structure can have one sub-schema clobber another sub-schema defined in a different top-level schema.
870
902
 
871
- config.avrogen.nest_child_schemas = false # Flatten all classes into one directory
872
-
873
- > **Note:** The schema-class generation settings moved from `config.schema.*` to
874
- > `config.avrogen.*`. The old `config.schema.generated_class_path` /
875
- > `nest_child_schemas` / `use_full_namespace` / `schema_namespace_map` settings still
876
- > work but are deprecated. Generated classes now inherit from
877
- > `AvroGen::SchemaClass::Record`/`Enum`; previously-generated files referencing
878
- > `Deimos::SchemaClass::*` still load, and `bundle exec rake avro:upgrade` rewrites them.
903
+ config.schema.nest_child_schemas = false # Flatten all classes into one directory
879
904
 
880
905
  You can generate a tombstone message (with only a key and no value) by calling the `YourSchemaClass.tombstone(key)` method. If you're using a `:field` key config, you can pass in just the key scalar value. If using a key schema, you can pass it in as a hash or as another schema class.
881
906
 
@@ -888,7 +913,7 @@ Examples of consumers would look like this:
888
913
  ```ruby
889
914
  class MyConsumer < Deimos::Consumer
890
915
  def consume_message(message)
891
- # Same method as before but message.payload is now an instance of AvroGen::SchemaClass::Record
916
+ # Same method as before but message.payload is now an instance of Deimos::SchemaClass::Record
892
917
  # rather than a hash.
893
918
  # You can interact with the schema class instance in the following way:
894
919
  do_something(message.payload.test_id, message.payload.some_int)
@@ -902,7 +927,7 @@ end
902
927
  class MyActiveRecordConsumer < Deimos::ActiveRecordConsumer
903
928
  record_class Widget
904
929
  # Any method that expects a message payload as a hash will instead
905
- # receive an instance of AvroGen::SchemaClass::Record.
930
+ # receive an instance of Deimos::SchemaClass::Record.
906
931
  def record_attributes(payload, key)
907
932
  # You can interact with the schema class instance in the following way:
908
933
  super.merge(:some_field => "some_value-#{payload.test_id}")
@@ -942,9 +967,9 @@ class MyActiveRecordProducer < Deimos::ActiveRecordProducer
942
967
  record_class Widget
943
968
  # @param attributes [Hash]
944
969
  # @param _record [Widget]
945
- # @return [AvroGen::SchemaClass::Record]
970
+ # @return [Deimos::SchemaClass::Record]
946
971
  def self.generate_payload(attributes, _record)
947
- # This method converts your ActiveRecord into a AvroGen::SchemaClass::Record. You will be able to use super
972
+ # This method converts your ActiveRecord into a Deimos::SchemaClass::Record. You will be able to use super
948
973
  # as an instance of Schemas::MySchema and set values that are not on your ActiveRecord schema.
949
974
  res = super
950
975
  res.some_value = "some_value-#{res.test_id}"
data/deimos-ruby.gemspec CHANGED
@@ -17,7 +17,6 @@ Gem::Specification.new do |spec|
17
17
  spec.executables = spec.files.grep(%r{^bin/}) { |f| File.basename(f) }
18
18
  spec.require_paths = ['lib']
19
19
 
20
- spec.add_dependency('avro-gen-ruby')
21
20
  spec.add_dependency('benchmark', '~> 0.5')
22
21
  spec.add_dependency('fig_tree', '~> 0.2.0')
23
22
  spec.add_dependency('karafka', '~> 2.0')
@@ -60,17 +60,10 @@ things you need to reference into local variables before calling `configure`.
60
60
  | schema.password | nil | Basic auth password. |
61
61
  | schema.path | nil | Local path to find your schemas. |
62
62
  | schema.use_schema_classes | false | Set this to true to use generated schema classes in your application. |
63
-
64
- #### Schema Class Generation (`avrogen`)
65
-
66
- Schema class generation is provided by the [avro-gen-ruby](https://github.com/flipp-oss/avro-gen-ruby) gem (namespace `AvroGen`). These settings are forwarded to `AvroGen.config`. The equivalent `schema.*` settings still work but are deprecated — using one prints a warning pointing to the `avrogen.*` setting, and you can migrate generated files with `rake avro:upgrade`.
67
-
68
- | Config name | Default | Description |
69
- |-------------------------------|--------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------|
70
- | avrogen.generated_class_path | `app/lib/schema_classes` | Local path to generated schema classes. (was `schema.generated_class_path`) |
71
- | avrogen.nest_child_schemas | true | Set to true to nest subschemas within the generated class for the parent schema. (was `schema.nest_child_schemas`) |
72
- | avrogen.use_full_namespace | false | Set to true to generate folders for schemas matching the full namespace. (was `schema.use_full_namespace`) |
73
- | avrogen.schema_namespace_map | {} | A map of namespace prefixes to base module name(s). Example: { 'com.mycompany.suborg' => ['SchemaClasses'] }. Requires `use_full_namespace` to be true. (was `schema.schema_namespace_map`) |
63
+ | schema.generated_class_path | `app/lib/schema_classes` | Local path to generated schema classes. |
64
+ | schema.nest_child_schemas | false | Set to true to nest subschemas within the generated class for the parent schema. |
65
+ | schema.use_full_namespace | false | Set to true to generate folders for schemas matching the full namespace. |
66
+ | schema.schema_namespace_map | {} | A map of namespace prefixes to base module name(s). Example: { 'com.mycompany.suborg' => ['SchemaClasses'] }. Requires `use_full_namespace` to be true. |
74
67
 
75
68
  ### Outbox Configuration
76
69
 
@@ -140,6 +133,7 @@ The following are additional settings that can be added to the `topic` block in
140
133
  | replace_associations | true | If false, append to associations in multi-table imports rather than replacing them. |
141
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. |
142
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. |
143
137
 
144
138
  ### Defining Consumers
145
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)
@@ -76,7 +81,7 @@ module Deimos
76
81
  def record_key(key)
77
82
  if key.nil?
78
83
  {}
79
- elsif key.is_a?(Hash) || key.is_a?(AvroGen::SchemaClass::Record)
84
+ elsif key.is_a?(Hash) || key.is_a?(SchemaClass::Record)
80
85
  self.key_converter.convert(key)
81
86
  elsif self.topic.key_config[:field].nil?
82
87
  { @klass.primary_key => key }
@@ -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
 
@@ -13,7 +13,7 @@ module Deimos
13
13
  # Default is to use the primary key column and the value of the first
14
14
  # field in the key.
15
15
  # @param klass [Class<ActiveRecord::Base>]
16
- # @param _payload [Hash,AvroGen::SchemaClass::Record]
16
+ # @param _payload [Hash,Deimos::SchemaClass::Record]
17
17
  # @param key [Object]
18
18
  # @return [ActiveRecord::Base]
19
19
  def fetch_record(klass, _payload, key)
@@ -23,7 +23,7 @@ module Deimos
23
23
 
24
24
  # Assign a key to a new record.
25
25
  # @param record [ActiveRecord::Base]
26
- # @param _payload [Hash,AvroGen::SchemaClass::Record]
26
+ # @param _payload [Hash,Deimos::SchemaClass::Record]
27
27
  # @param key [Object]
28
28
  # @return [void]
29
29
  def assign_key(record, _payload, key)
@@ -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
@@ -90,7 +95,7 @@ module Deimos
90
95
 
91
96
  # Override this method (with `super`) if you want to add/change the default
92
97
  # attributes set to the new/existing record.
93
- # @param payload [Hash,AvroGen::SchemaClass::Record]
98
+ # @param payload [Hash,Deimos::SchemaClass::Record]
94
99
  # @param _key [String]
95
100
  # @return [Hash]
96
101
  def record_attributes(payload, _key=nil)
@@ -81,7 +81,7 @@ module Deimos
81
81
  return payload if self.karafka_config.use_schema_classes.nil? &&
82
82
  !Deimos.config.schema.use_schema_classes
83
83
 
84
- AvroGen::SchemaClass.instance(payload, encoder.schema, encoder.namespace)
84
+ Utils::SchemaClass.instance(payload, encoder.schema, encoder.namespace)
85
85
  end
86
86
 
87
87
  # Deletion payload for a record by default, delegate to the
@@ -15,7 +15,6 @@ module Deimos
15
15
  config.deserializers[:payload].try(:reset_backend)
16
16
  config.deserializers[:key].try(:reset_backend)
17
17
  end
18
- Deimos::SchemaClass.sync_config!
19
18
  if self.config.schema.use_schema_classes
20
19
  load_generated_schema_classes
21
20
  end
@@ -43,16 +42,16 @@ module Deimos
43
42
  # Loads generated classes
44
43
  # @return [void]
45
44
  def load_generated_schema_classes
46
- path = AvroGen.config.generated_class_path
47
- if path.nil?
48
- raise 'Cannot use schema classes without a generated class path. ' \
49
- 'Please set AvroGen.config.generated_class_path.'
45
+ if Deimos.config.schema.generated_class_path.nil?
46
+ raise 'Cannot use schema classes without schema.generated_class_path. ' \
47
+ 'Please provide a directory.'
50
48
  end
51
49
 
52
- Dir["./#{path}/**/*.rb"].
50
+ Dir["./#{Deimos.config.schema.generated_class_path}/**/*.rb"].
53
51
  each { |f| require f }
54
52
  rescue LoadError
55
- raise 'Cannot load schema classes. Please regenerate classes with rake avro:generate.'
53
+ raise 'Cannot load schema classes. Please regenerate classes with' \
54
+ 'rake deimos:generate_schema_models.'
56
55
  end
57
56
 
58
57
  # Ensure everything is set up correctly for the DB backend.
@@ -174,7 +173,7 @@ module Deimos
174
173
  # @return [String]
175
174
  setting :path
176
175
 
177
- # @deprecated Use config.avrogen.generated_class_path instead.
176
+ # Local path for schema classes to be generated in.
178
177
  # @return [String]
179
178
  setting :generated_class_path, 'app/lib/schema_classes'
180
179
 
@@ -182,31 +181,6 @@ module Deimos
182
181
  # @return [Boolean]
183
182
  setting :use_schema_classes
184
183
 
185
- # @deprecated Use config.avrogen.nest_child_schemas instead.
186
- # @return [Boolean]
187
- setting :nest_child_schemas, true
188
-
189
- # @deprecated Use config.avrogen.use_full_namespace instead.
190
- # @return [Boolean]
191
- setting :use_full_namespace, false
192
-
193
- # @deprecated Use config.avrogen.schema_namespace_map instead.
194
- # @return [Hash]
195
- setting :schema_namespace_map, {}
196
-
197
- # The base directory for generated protobuf key schemas.
198
- setting :proto_schema_key_path, 'protos'
199
- end
200
-
201
- # Schema class generation settings, forwarded to the avro-gen-ruby gem
202
- # (AvroGen.config). These previously lived under `schema` (still supported,
203
- # but deprecated).
204
- setting :avrogen do
205
-
206
- # Local path for schema classes to be generated in.
207
- # @return [String]
208
- setting :generated_class_path, 'app/lib/schema_classes'
209
-
210
184
  # Set to false to generate child schemas as their own files.
211
185
  # @return [Boolean]
212
186
  setting :nest_child_schemas, true
@@ -217,9 +191,12 @@ module Deimos
217
191
 
218
192
  # Use this option to reduce nesting when using use_full_namespace.
219
193
  # For example: { 'com.mycompany.suborg' => 'SchemaClasses' }
220
- # would replace a prefix matching the given key with the module name SchemaClasses.
194
+ # would replace a prefixed with the given key with the module name SchemaClasses.
221
195
  # @return [Hash]
222
196
  setting :schema_namespace_map, {}
197
+
198
+ # The base directory for generated protobuf key schemas.
199
+ setting :proto_schema_key_path, 'protos'
223
200
  end
224
201
 
225
202
  # The configured metrics provider.
@@ -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 }
@@ -5,7 +5,7 @@ module Deimos
5
5
  class << self
6
6
 
7
7
  def allowed_classes
8
- arr = [Hash, AvroGen::SchemaClass::Record]
8
+ arr = [Hash, SchemaClass::Record]
9
9
  if defined?(Google::Protobuf)
10
10
  arr.push(Google::Protobuf.const_get(:AbstractMessage))
11
11
  end
@@ -26,7 +26,7 @@ module Deimos
26
26
  self.allowed_classes.none? { |k| message[:payload].is_a?(k) }
27
27
 
28
28
  payload = message[:payload]
29
- payload = payload.to_h if payload.nil? || payload.is_a?(AvroGen::SchemaClass::Record)
29
+ payload = payload.to_h if payload.nil? || payload.is_a?(SchemaClass::Record)
30
30
  m = Deimos::Message.new(payload,
31
31
  headers: message[:headers],
32
32
  partition_key: message[:partition_key])
@@ -30,15 +30,6 @@ module Deimos
30
30
  log_add(:warn, *args)
31
31
  end
32
32
 
33
- # Emit a deprecation warning at most once per unique message.
34
- # @param msg [String]
35
- def deprecate(msg)
36
- @deprecations ||= Set.new
37
- return unless @deprecations.add?(msg)
38
-
39
- warn("DEPRECATION WARNING: #{msg}")
40
- end
41
-
42
33
  def metadata_log_text(metadata)
43
34
  metadata.to_h.slice(:timestamp, :offset, :first_offset, :last_offset, :partition, :topic, :size)
44
35
  end