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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +8 -2
- data/README.md +45 -20
- data/deimos-ruby.gemspec +0 -1
- data/docs/CONFIGURATION.md +5 -11
- data/lib/deimos/active_record_consume/batch_consumption.rb +128 -31
- data/lib/deimos/active_record_consume/batch_record.rb +3 -0
- data/lib/deimos/active_record_consume/message_consumption.rb +2 -2
- data/lib/deimos/active_record_consumer.rb +6 -1
- data/lib/deimos/active_record_producer.rb +1 -1
- data/lib/deimos/config/configuration.rb +11 -34
- data/lib/deimos/exceptions.rb +17 -0
- data/lib/deimos/ext/consumer_route.rb +3 -1
- data/lib/deimos/ext/producer_middleware.rb +2 -2
- data/lib/deimos/logging.rb +0 -9
- data/lib/deimos/producer.rb +5 -5
- data/lib/deimos/schema_backends/avro_base.rb +36 -7
- data/lib/deimos/schema_class/base.rb +62 -0
- data/lib/deimos/schema_class/enum.rb +49 -0
- data/lib/deimos/schema_class/record.rb +100 -0
- data/lib/deimos/transcoder.rb +6 -6
- data/lib/deimos/utils/deadlock_retry.rb +16 -2
- data/lib/deimos/utils/schema_class.rb +47 -14
- data/lib/deimos/version.rb +1 -1
- data/lib/deimos.rb +4 -3
- data/lib/generators/deimos/schema_class/templates/schema_class.rb.tt +8 -0
- data/lib/generators/deimos/schema_class/templates/schema_enum.rb.tt +13 -0
- data/lib/generators/deimos/schema_class/templates/schema_record.rb.tt +102 -0
- data/lib/generators/deimos/schema_class_generator.rb +369 -0
- data/lib/tasks/deimos.rake +4 -16
- data/regenerate_test_schema_classes.rb +3 -12
- data/spec/active_record_batch_consumer_association_spec.rb +1 -1
- data/spec/active_record_batch_consumer_spec.rb +286 -1
- data/spec/active_record_consume/batch_consumption_spec.rb +1 -1
- data/spec/active_record_consumer_spec.rb +3 -3
- data/spec/active_record_producer_spec.rb +1 -1
- data/spec/batch_consumer_spec.rb +2 -2
- data/spec/consumer_spec.rb +5 -5
- data/spec/generators/schema_class/my_schema_spec.rb +16 -0
- data/spec/generators/schema_class/my_schema_with_circular_reference_spec.rb +98 -0
- data/spec/generators/schema_class/my_schema_with_complex_types_spec.rb +237 -0
- data/spec/generators/schema_class_generator_spec.rb +283 -0
- data/spec/schemas/my_namespace/generated.rb +4 -4
- data/spec/schemas/my_namespace/my_long_namespace_schema.rb +2 -2
- data/spec/schemas/my_namespace/my_nested_schema.rb +3 -3
- data/spec/schemas/my_namespace/my_schema.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_compound_key.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_id_key.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_key.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_with_boolean.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_with_circular_reference.rb +3 -3
- data/spec/schemas/my_namespace/my_schema_with_complex_type.rb +6 -6
- data/spec/schemas/my_namespace/my_schema_with_date_time.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_with_id.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_with_title.rb +2 -2
- data/spec/schemas/my_namespace/my_schema_with_union_type.rb +6 -6
- data/spec/schemas/my_namespace/my_schema_with_unique_id.rb +2 -2
- data/spec/schemas/my_namespace/my_updated_schema.rb +1 -1
- data/spec/schemas/my_namespace/request/create_topic.rb +2 -2
- data/spec/schemas/my_namespace/request/index.rb +2 -2
- data/spec/schemas/my_namespace/request/update_request.rb +2 -2
- data/spec/schemas/my_namespace/response/create_topic.rb +2 -2
- data/spec/schemas/my_namespace/response/index.rb +2 -2
- data/spec/schemas/my_namespace/response/update_response.rb +2 -2
- data/spec/schemas/my_namespace/wibble.rb +2 -2
- data/spec/schemas/my_namespace/widget.rb +2 -2
- data/spec/schemas/my_namespace/widget_the_second.rb +2 -2
- data/spec/schemas/my_namespace/widget_the_third.rb +2 -2
- data/spec/snapshots/consumers-no-nest.snap +1740 -0
- data/spec/snapshots/consumers.snap +1720 -0
- data/spec/snapshots/consumers_and_producers-no-nest.snap +1740 -0
- data/spec/snapshots/consumers_and_producers.snap +1720 -0
- data/spec/snapshots/consumers_circular-no-nest.snap +1740 -0
- data/spec/snapshots/consumers_circular.snap +1720 -0
- data/spec/snapshots/consumers_complex_types-no-nest.snap +1740 -0
- data/spec/snapshots/consumers_complex_types.snap +1720 -0
- data/spec/snapshots/consumers_nested-no-nest.snap +1740 -0
- data/spec/snapshots/consumers_nested.snap +1720 -0
- data/spec/snapshots/namespace_folders.snap +1800 -0
- data/spec/snapshots/namespace_map.snap +1800 -0
- data/spec/snapshots/producers_with_key-no-nest.snap +1740 -0
- data/spec/snapshots/producers_with_key.snap +1720 -0
- data/spec/spec_helper.rb +2 -2
- data/spec/utils/db_poller_spec.rb +1 -1
- data/spec/utils/deadlock_retry_spec.rb +57 -0
- metadata +27 -18
- data/lib/deimos/schema_class.rb +0 -72
- data/spec/schema_class_spec.rb +0 -65
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,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
|
-
##
|
|
8
|
+
## 2.6.0 - 2026-08-13
|
|
9
9
|
|
|
10
|
-
-
|
|
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
|
|
844
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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 [
|
|
970
|
+
# @return [Deimos::SchemaClass::Record]
|
|
946
971
|
def self.generate_payload(attributes, _record)
|
|
947
|
-
# This method converts your ActiveRecord into a
|
|
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')
|
data/docs/CONFIGURATION.md
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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)
|
|
@@ -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?(
|
|
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 [
|
|
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
|
|
|
@@ -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,
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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["./#{
|
|
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
|
|
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
|
-
#
|
|
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
|
|
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.
|
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 }
|
|
@@ -5,7 +5,7 @@ module Deimos
|
|
|
5
5
|
class << self
|
|
6
6
|
|
|
7
7
|
def allowed_classes
|
|
8
|
-
arr = [Hash,
|
|
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?(
|
|
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])
|
data/lib/deimos/logging.rb
CHANGED
|
@@ -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
|