deimos-temp-fork 0.0.1
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 +7 -0
- data/.circleci/config.yml +83 -0
- data/.gitignore +41 -0
- data/.gitmodules +0 -0
- data/.rspec +1 -0
- data/.rubocop.yml +333 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +349 -0
- data/CODE_OF_CONDUCT.md +77 -0
- data/Dockerfile +23 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +286 -0
- data/Guardfile +22 -0
- data/LICENSE.md +195 -0
- data/README.md +1099 -0
- data/Rakefile +13 -0
- data/bin/deimos +4 -0
- data/deimos-ruby.gemspec +44 -0
- data/docker-compose.yml +71 -0
- data/docs/ARCHITECTURE.md +140 -0
- data/docs/CONFIGURATION.md +236 -0
- data/docs/DATABASE_BACKEND.md +147 -0
- data/docs/INTEGRATION_TESTS.md +52 -0
- data/docs/PULL_REQUEST_TEMPLATE.md +35 -0
- data/docs/UPGRADING.md +128 -0
- data/lib/deimos-temp-fork.rb +95 -0
- data/lib/deimos/active_record_consume/batch_consumption.rb +164 -0
- data/lib/deimos/active_record_consume/batch_slicer.rb +27 -0
- data/lib/deimos/active_record_consume/message_consumption.rb +79 -0
- data/lib/deimos/active_record_consume/schema_model_converter.rb +52 -0
- data/lib/deimos/active_record_consumer.rb +67 -0
- data/lib/deimos/active_record_producer.rb +87 -0
- data/lib/deimos/backends/base.rb +32 -0
- data/lib/deimos/backends/db.rb +41 -0
- data/lib/deimos/backends/kafka.rb +33 -0
- data/lib/deimos/backends/kafka_async.rb +33 -0
- data/lib/deimos/backends/test.rb +20 -0
- data/lib/deimos/batch_consumer.rb +7 -0
- data/lib/deimos/config/configuration.rb +381 -0
- data/lib/deimos/config/phobos_config.rb +137 -0
- data/lib/deimos/consume/batch_consumption.rb +150 -0
- data/lib/deimos/consume/message_consumption.rb +94 -0
- data/lib/deimos/consumer.rb +104 -0
- data/lib/deimos/instrumentation.rb +76 -0
- data/lib/deimos/kafka_message.rb +60 -0
- data/lib/deimos/kafka_source.rb +128 -0
- data/lib/deimos/kafka_topic_info.rb +102 -0
- data/lib/deimos/message.rb +79 -0
- data/lib/deimos/metrics/datadog.rb +47 -0
- data/lib/deimos/metrics/mock.rb +39 -0
- data/lib/deimos/metrics/provider.rb +36 -0
- data/lib/deimos/monkey_patches/phobos_cli.rb +35 -0
- data/lib/deimos/monkey_patches/phobos_producer.rb +51 -0
- data/lib/deimos/poll_info.rb +9 -0
- data/lib/deimos/producer.rb +224 -0
- data/lib/deimos/railtie.rb +8 -0
- data/lib/deimos/schema_backends/avro_base.rb +140 -0
- data/lib/deimos/schema_backends/avro_local.rb +30 -0
- data/lib/deimos/schema_backends/avro_schema_coercer.rb +119 -0
- data/lib/deimos/schema_backends/avro_schema_registry.rb +34 -0
- data/lib/deimos/schema_backends/avro_validation.rb +21 -0
- data/lib/deimos/schema_backends/base.rb +150 -0
- data/lib/deimos/schema_backends/mock.rb +42 -0
- data/lib/deimos/shared_config.rb +63 -0
- data/lib/deimos/test_helpers.rb +360 -0
- data/lib/deimos/tracing/datadog.rb +35 -0
- data/lib/deimos/tracing/mock.rb +40 -0
- data/lib/deimos/tracing/provider.rb +29 -0
- data/lib/deimos/utils/db_poller.rb +150 -0
- data/lib/deimos/utils/db_producer.rb +243 -0
- data/lib/deimos/utils/deadlock_retry.rb +68 -0
- data/lib/deimos/utils/inline_consumer.rb +150 -0
- data/lib/deimos/utils/lag_reporter.rb +175 -0
- data/lib/deimos/utils/schema_controller_mixin.rb +115 -0
- data/lib/deimos/version.rb +5 -0
- data/lib/generators/deimos/active_record/templates/migration.rb.tt +28 -0
- data/lib/generators/deimos/active_record/templates/model.rb.tt +5 -0
- data/lib/generators/deimos/active_record_generator.rb +79 -0
- data/lib/generators/deimos/db_backend/templates/migration +25 -0
- data/lib/generators/deimos/db_backend/templates/rails3_migration +31 -0
- data/lib/generators/deimos/db_backend_generator.rb +48 -0
- data/lib/generators/deimos/db_poller/templates/migration +11 -0
- data/lib/generators/deimos/db_poller/templates/rails3_migration +16 -0
- data/lib/generators/deimos/db_poller_generator.rb +48 -0
- data/lib/tasks/deimos.rake +34 -0
- data/spec/active_record_batch_consumer_spec.rb +481 -0
- data/spec/active_record_consume/batch_slicer_spec.rb +42 -0
- data/spec/active_record_consume/schema_model_converter_spec.rb +105 -0
- data/spec/active_record_consumer_spec.rb +154 -0
- data/spec/active_record_producer_spec.rb +85 -0
- data/spec/backends/base_spec.rb +10 -0
- data/spec/backends/db_spec.rb +54 -0
- data/spec/backends/kafka_async_spec.rb +11 -0
- data/spec/backends/kafka_spec.rb +11 -0
- data/spec/batch_consumer_spec.rb +256 -0
- data/spec/config/configuration_spec.rb +248 -0
- data/spec/consumer_spec.rb +209 -0
- data/spec/deimos_spec.rb +169 -0
- data/spec/generators/active_record_generator_spec.rb +56 -0
- data/spec/handlers/my_batch_consumer.rb +10 -0
- data/spec/handlers/my_consumer.rb +10 -0
- data/spec/kafka_listener_spec.rb +55 -0
- data/spec/kafka_source_spec.rb +381 -0
- data/spec/kafka_topic_info_spec.rb +111 -0
- data/spec/message_spec.rb +19 -0
- data/spec/phobos.bad_db.yml +73 -0
- data/spec/phobos.yml +77 -0
- data/spec/producer_spec.rb +498 -0
- data/spec/rake_spec.rb +19 -0
- data/spec/schema_backends/avro_base_shared.rb +199 -0
- data/spec/schema_backends/avro_local_spec.rb +32 -0
- data/spec/schema_backends/avro_schema_registry_spec.rb +32 -0
- data/spec/schema_backends/avro_validation_spec.rb +24 -0
- data/spec/schema_backends/base_spec.rb +33 -0
- data/spec/schemas/com/my-namespace/Generated.avsc +71 -0
- data/spec/schemas/com/my-namespace/MyNestedSchema.avsc +62 -0
- data/spec/schemas/com/my-namespace/MySchema-key.avsc +13 -0
- data/spec/schemas/com/my-namespace/MySchema.avsc +18 -0
- data/spec/schemas/com/my-namespace/MySchemaCompound-key.avsc +18 -0
- data/spec/schemas/com/my-namespace/MySchemaWithBooleans.avsc +18 -0
- data/spec/schemas/com/my-namespace/MySchemaWithDateTimes.avsc +33 -0
- data/spec/schemas/com/my-namespace/MySchemaWithId.avsc +28 -0
- data/spec/schemas/com/my-namespace/MySchemaWithUniqueId.avsc +32 -0
- data/spec/schemas/com/my-namespace/Wibble.avsc +43 -0
- data/spec/schemas/com/my-namespace/Widget.avsc +27 -0
- data/spec/schemas/com/my-namespace/WidgetTheSecond.avsc +27 -0
- data/spec/schemas/com/my-namespace/request/CreateTopic.avsc +11 -0
- data/spec/schemas/com/my-namespace/request/Index.avsc +11 -0
- data/spec/schemas/com/my-namespace/request/UpdateRequest.avsc +11 -0
- data/spec/schemas/com/my-namespace/response/CreateTopic.avsc +11 -0
- data/spec/schemas/com/my-namespace/response/Index.avsc +11 -0
- data/spec/schemas/com/my-namespace/response/UpdateResponse.avsc +11 -0
- data/spec/spec_helper.rb +267 -0
- data/spec/utils/db_poller_spec.rb +320 -0
- data/spec/utils/db_producer_spec.rb +514 -0
- data/spec/utils/deadlock_retry_spec.rb +74 -0
- data/spec/utils/inline_consumer_spec.rb +31 -0
- data/spec/utils/lag_reporter_spec.rb +76 -0
- data/spec/utils/platform_schema_validation_spec.rb +0 -0
- data/spec/utils/schema_controller_mixin_spec.rb +84 -0
- data/support/deimos-solo.png +0 -0
- data/support/deimos-with-name-next.png +0 -0
- data/support/deimos-with-name.png +0 -0
- data/support/flipp-logo.png +0 -0
- metadata +551 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'active_support/notifications'
|
|
4
|
+
require 'active_support/concern'
|
|
5
|
+
|
|
6
|
+
# :nodoc:
|
|
7
|
+
module Deimos
|
|
8
|
+
# Copied from Phobos instrumentation.
|
|
9
|
+
module Instrumentation
|
|
10
|
+
extend ActiveSupport::Concern
|
|
11
|
+
NAMESPACE = 'Deimos'
|
|
12
|
+
|
|
13
|
+
# :nodoc:
|
|
14
|
+
module ClassMethods
|
|
15
|
+
# :nodoc:
|
|
16
|
+
def subscribe(event)
|
|
17
|
+
ActiveSupport::Notifications.subscribe("#{NAMESPACE}.#{event}") do |*args|
|
|
18
|
+
yield(ActiveSupport::Notifications::Event.new(*args)) if block_given?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# :nodoc:
|
|
23
|
+
def unsubscribe(subscriber)
|
|
24
|
+
ActiveSupport::Notifications.unsubscribe(subscriber)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# :nodoc:
|
|
28
|
+
def instrument(event, extra={})
|
|
29
|
+
ActiveSupport::Notifications.instrument("#{NAMESPACE}.#{event}", extra) do |extra2|
|
|
30
|
+
yield(extra2) if block_given?
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
include Instrumentation
|
|
37
|
+
|
|
38
|
+
# This module listens to events published by RubyKafka.
|
|
39
|
+
module KafkaListener
|
|
40
|
+
# Listens for any exceptions that happen during publishing and re-publishes
|
|
41
|
+
# as a Deimos event.
|
|
42
|
+
# @param event [ActiveSupport::Notification]
|
|
43
|
+
def self.send_produce_error(event)
|
|
44
|
+
exception = event.payload[:exception_object]
|
|
45
|
+
return if !exception || !exception.respond_to?(:failed_messages)
|
|
46
|
+
|
|
47
|
+
messages = exception.failed_messages
|
|
48
|
+
messages.group_by(&:topic).each do |topic, batch|
|
|
49
|
+
producer = Deimos::Producer.descendants.find { |c| c.topic == topic }
|
|
50
|
+
next if batch.empty? || !producer
|
|
51
|
+
|
|
52
|
+
decoder = Deimos.schema_backend(schema: producer.config[:schema],
|
|
53
|
+
namespace: producer.config[:namespace])
|
|
54
|
+
payloads = batch.map { |m| decoder.decode(m.value) }
|
|
55
|
+
|
|
56
|
+
Deimos.config.metrics&.increment(
|
|
57
|
+
'publish_error',
|
|
58
|
+
tags: %W(topic:#{topic}),
|
|
59
|
+
by: payloads.size
|
|
60
|
+
)
|
|
61
|
+
Deimos.instrument(
|
|
62
|
+
'produce_error',
|
|
63
|
+
producer: producer,
|
|
64
|
+
topic: topic,
|
|
65
|
+
exception_object: exception,
|
|
66
|
+
payloads: payloads
|
|
67
|
+
)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
ActiveSupport::Notifications.subscribe('deliver_messages.producer.kafka') do |*args|
|
|
73
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
|
74
|
+
KafkaListener.send_produce_error(event)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deimos
|
|
4
|
+
# Store Kafka messages into the database.
|
|
5
|
+
class KafkaMessage < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'kafka_messages'
|
|
7
|
+
|
|
8
|
+
validates_presence_of :topic
|
|
9
|
+
|
|
10
|
+
# Ensure it gets turned into a string, e.g. for testing purposes. It
|
|
11
|
+
# should already be a string.
|
|
12
|
+
# @param mess [Object]
|
|
13
|
+
def message=(mess)
|
|
14
|
+
write_attribute(:message, mess ? mess.to_s : nil)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Decoded payload for this message.
|
|
18
|
+
# @return [Hash]
|
|
19
|
+
def decoded_message
|
|
20
|
+
self.class.decoded([self]).first
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Get a decoder to decode a set of messages on the given topic.
|
|
24
|
+
# @param topic [String]
|
|
25
|
+
# @return [Deimos::Consumer]
|
|
26
|
+
def self.decoder(topic)
|
|
27
|
+
producer = Deimos::Producer.descendants.find { |c| c.topic == topic }
|
|
28
|
+
return nil unless producer
|
|
29
|
+
|
|
30
|
+
consumer = Class.new(Deimos::Consumer)
|
|
31
|
+
consumer.config.merge!(producer.config)
|
|
32
|
+
consumer
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Decoded payloads for a list of messages.
|
|
36
|
+
# @param messages [Array<Deimos::KafkaMessage>]
|
|
37
|
+
# @return [Array<Hash>]
|
|
38
|
+
def self.decoded(messages=[])
|
|
39
|
+
return [] if messages.empty?
|
|
40
|
+
|
|
41
|
+
decoder = self.decoder(messages.first.topic)&.new
|
|
42
|
+
messages.map do |m|
|
|
43
|
+
{
|
|
44
|
+
key: m.key.present? ? decoder&.decode_key(m.key) || m.key : nil,
|
|
45
|
+
payload: decoder&.decoder&.decode(m.message) || m.message
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Hash]
|
|
51
|
+
def phobos_message
|
|
52
|
+
{
|
|
53
|
+
payload: self.message,
|
|
54
|
+
partition_key: self.partition_key,
|
|
55
|
+
key: self.key,
|
|
56
|
+
topic: self.topic
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deimos
|
|
4
|
+
# Represents an object which needs to inform Kafka when it is saved or
|
|
5
|
+
# bulk imported.
|
|
6
|
+
module KafkaSource
|
|
7
|
+
extend ActiveSupport::Concern
|
|
8
|
+
|
|
9
|
+
DEPRECATION_WARNING = 'The kafka_producer interface will be deprecated ' \
|
|
10
|
+
'in future releases. Please use kafka_producers instead.'
|
|
11
|
+
|
|
12
|
+
included do
|
|
13
|
+
after_create(:send_kafka_event_on_create)
|
|
14
|
+
after_update(:send_kafka_event_on_update)
|
|
15
|
+
after_destroy(:send_kafka_event_on_destroy)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Send the newly created model to Kafka.
|
|
19
|
+
def send_kafka_event_on_create
|
|
20
|
+
return unless self.persisted?
|
|
21
|
+
return unless self.class.kafka_config[:create]
|
|
22
|
+
|
|
23
|
+
self.class.kafka_producers.each { |p| p.send_event(self) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Send the newly updated model to Kafka.
|
|
27
|
+
def send_kafka_event_on_update
|
|
28
|
+
return unless self.class.kafka_config[:update]
|
|
29
|
+
|
|
30
|
+
producers = self.class.kafka_producers
|
|
31
|
+
fields = producers.flat_map(&:watched_attributes).uniq
|
|
32
|
+
fields -= ['updated_at']
|
|
33
|
+
# Only send an event if a field we care about was changed.
|
|
34
|
+
any_changes = fields.any? do |field|
|
|
35
|
+
field_change = self.previous_changes[field]
|
|
36
|
+
field_change.present? && field_change[0] != field_change[1]
|
|
37
|
+
end
|
|
38
|
+
return unless any_changes
|
|
39
|
+
|
|
40
|
+
producers.each { |p| p.send_event(self) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Send a deletion (null payload) event to Kafka.
|
|
44
|
+
def send_kafka_event_on_destroy
|
|
45
|
+
return unless self.class.kafka_config[:delete]
|
|
46
|
+
|
|
47
|
+
self.class.kafka_producers.each { |p| p.publish_list([self.deletion_payload]) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Payload to send after we are destroyed.
|
|
51
|
+
# @return [Hash]
|
|
52
|
+
def deletion_payload
|
|
53
|
+
{ payload_key: self[self.class.primary_key] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# :nodoc:
|
|
57
|
+
module ClassMethods
|
|
58
|
+
# @return [Hash]
|
|
59
|
+
def kafka_config
|
|
60
|
+
{
|
|
61
|
+
update: true,
|
|
62
|
+
delete: true,
|
|
63
|
+
import: true,
|
|
64
|
+
create: true
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [Array<Deimos::ActiveRecordProducer>] the producers to run.
|
|
69
|
+
def kafka_producers
|
|
70
|
+
if self.respond_to?(:kafka_producer)
|
|
71
|
+
Deimos.config.logger.warn(message: DEPRECATION_WARNING)
|
|
72
|
+
return [self.kafka_producer]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# This is an internal method, part of the activerecord_import gem. It's
|
|
79
|
+
# the one that actually does the importing, having already normalized
|
|
80
|
+
# the inputs (arrays, hashes, records etc.)
|
|
81
|
+
# Basically we want to first do the import, then reload the records
|
|
82
|
+
# and send them to Kafka.
|
|
83
|
+
def import_without_validations_or_callbacks(column_names,
|
|
84
|
+
array_of_attributes,
|
|
85
|
+
options={})
|
|
86
|
+
results = super
|
|
87
|
+
if !self.kafka_config[:import] || array_of_attributes.empty?
|
|
88
|
+
return results
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# This will contain an array of hashes, where each hash is the actual
|
|
92
|
+
# attribute hash that created the object.
|
|
93
|
+
array_of_hashes = []
|
|
94
|
+
array_of_attributes.each do |array|
|
|
95
|
+
array_of_hashes << column_names.zip(array).to_h.with_indifferent_access
|
|
96
|
+
end
|
|
97
|
+
hashes_with_id, hashes_without_id = array_of_hashes.partition { |arr| arr[:id].present? }
|
|
98
|
+
|
|
99
|
+
self.kafka_producers.each { |p| p.send_events(hashes_with_id) }
|
|
100
|
+
|
|
101
|
+
if hashes_without_id.any?
|
|
102
|
+
if options[:on_duplicate_key_update].present? &&
|
|
103
|
+
options[:on_duplicate_key_update] != [:updated_at]
|
|
104
|
+
unique_columns = column_names.map(&:to_s) -
|
|
105
|
+
options[:on_duplicate_key_update].map(&:to_s) - %w(id created_at)
|
|
106
|
+
records = hashes_without_id.map do |hash|
|
|
107
|
+
self.where(unique_columns.map { |c| [c, hash[c]] }.to_h).first
|
|
108
|
+
end
|
|
109
|
+
self.kafka_producers.each { |p| p.send_events(records) }
|
|
110
|
+
else
|
|
111
|
+
# re-fill IDs based on what was just entered into the DB.
|
|
112
|
+
last_id = if self.connection.adapter_name.downcase =~ /sqlite/
|
|
113
|
+
self.connection.select_value('select last_insert_rowid()') -
|
|
114
|
+
hashes_without_id.size + 1
|
|
115
|
+
else # mysql
|
|
116
|
+
self.connection.select_value('select LAST_INSERT_ID()')
|
|
117
|
+
end
|
|
118
|
+
hashes_without_id.each_with_index do |attrs, i|
|
|
119
|
+
attrs[:id] = last_id + i
|
|
120
|
+
end
|
|
121
|
+
self.kafka_producers.each { |p| p.send_events(hashes_without_id) }
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
results
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deimos
|
|
4
|
+
# Record that keeps track of which topics are being worked on by DbProducers.
|
|
5
|
+
class KafkaTopicInfo < ActiveRecord::Base
|
|
6
|
+
self.table_name = 'kafka_topic_info'
|
|
7
|
+
|
|
8
|
+
class << self
|
|
9
|
+
# Lock a topic for the given ID. Returns whether the lock was successful.
|
|
10
|
+
# @param topic [String]
|
|
11
|
+
# @param lock_id [String]
|
|
12
|
+
# @return [Boolean]
|
|
13
|
+
def lock(topic, lock_id)
|
|
14
|
+
# Try to create it - it's fine if it already exists
|
|
15
|
+
begin
|
|
16
|
+
self.create(topic: topic, last_processed_at: Time.zone.now)
|
|
17
|
+
rescue ActiveRecord::RecordNotUnique
|
|
18
|
+
# continue on
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Lock the record
|
|
22
|
+
qtopic = self.connection.quote(topic)
|
|
23
|
+
qlock_id = self.connection.quote(lock_id)
|
|
24
|
+
qtable = self.connection.quote_table_name('kafka_topic_info')
|
|
25
|
+
qnow = self.connection.quote(Time.zone.now.to_s(:db))
|
|
26
|
+
qfalse = self.connection.quoted_false
|
|
27
|
+
qtime = self.connection.quote(1.minute.ago.to_s(:db))
|
|
28
|
+
|
|
29
|
+
# If a record is marked as error and less than 1 minute old,
|
|
30
|
+
# we don't want to pick it up even if not currently locked because
|
|
31
|
+
# we worry we'll run into the same problem again.
|
|
32
|
+
# Once it's more than 1 minute old, we figure it's OK to try again
|
|
33
|
+
# so we can pick up any topic that's that old, even if it was
|
|
34
|
+
# locked by someone, because it's the job of the producer to keep
|
|
35
|
+
# updating the locked_at timestamp as they work on messages in that
|
|
36
|
+
# topic. If the locked_at timestamp is that old, chances are that
|
|
37
|
+
# the producer crashed.
|
|
38
|
+
sql = <<~SQL
|
|
39
|
+
UPDATE #{qtable}
|
|
40
|
+
SET locked_by=#{qlock_id}, locked_at=#{qnow}, error=#{qfalse}
|
|
41
|
+
WHERE topic=#{qtopic} AND
|
|
42
|
+
((locked_by IS NULL AND error=#{qfalse}) OR locked_at < #{qtime})
|
|
43
|
+
SQL
|
|
44
|
+
self.connection.update(sql)
|
|
45
|
+
self.where(locked_by: lock_id, topic: topic).any?
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# This is called once a producer is finished working on a topic, i.e.
|
|
49
|
+
# there are no more messages to fetch. It unlocks the topic and
|
|
50
|
+
# moves on to the next one.
|
|
51
|
+
# @param topic [String]
|
|
52
|
+
# @param lock_id [String]
|
|
53
|
+
def clear_lock(topic, lock_id)
|
|
54
|
+
self.where(topic: topic, locked_by: lock_id).
|
|
55
|
+
update_all(locked_by: nil,
|
|
56
|
+
locked_at: nil,
|
|
57
|
+
error: false,
|
|
58
|
+
retries: 0,
|
|
59
|
+
last_processed_at: Time.zone.now)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Update all topics that aren't currently locked and have no messages
|
|
63
|
+
# waiting. It's OK if some messages get inserted in the middle of this
|
|
64
|
+
# because the point is that at least within a few milliseconds of each
|
|
65
|
+
# other, it wasn't locked and had no messages, meaning the topic
|
|
66
|
+
# was in a good state.
|
|
67
|
+
# @param except_topics [Array<String>] the list of topics we've just
|
|
68
|
+
# realized had messages in them, meaning all other topics were empty.
|
|
69
|
+
def ping_empty_topics(except_topics)
|
|
70
|
+
records = KafkaTopicInfo.where(locked_by: nil).
|
|
71
|
+
where('topic not in(?)', except_topics)
|
|
72
|
+
records.each do |info|
|
|
73
|
+
info.update_attribute(:last_processed_at, Time.zone.now)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The producer calls this if it gets an error sending messages. This
|
|
78
|
+
# essentially locks down this topic for 1 minute (for all producers)
|
|
79
|
+
# and allows the caller to continue to the next topic.
|
|
80
|
+
# @param topic [String]
|
|
81
|
+
# @param lock_id [String]
|
|
82
|
+
def register_error(topic, lock_id)
|
|
83
|
+
record = self.where(topic: topic, locked_by: lock_id).last
|
|
84
|
+
attr_hash = { locked_by: nil,
|
|
85
|
+
locked_at: Time.zone.now,
|
|
86
|
+
error: true,
|
|
87
|
+
retries: record.retries + 1 }
|
|
88
|
+
record.attributes = attr_hash
|
|
89
|
+
record.save!
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Update the locked_at timestamp to indicate that the producer is still
|
|
93
|
+
# working on those messages and to continue.
|
|
94
|
+
# @param topic [String]
|
|
95
|
+
# @param lock_id [String]
|
|
96
|
+
def heartbeat(topic, lock_id)
|
|
97
|
+
self.where(topic: topic, locked_by: lock_id).
|
|
98
|
+
update_all(locked_at: Time.zone.now)
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Deimos
|
|
4
|
+
# Basically a struct to hold the message as it's processed.
|
|
5
|
+
class Message
|
|
6
|
+
attr_accessor :payload, :key, :partition_key, :encoded_key,
|
|
7
|
+
:encoded_payload, :topic, :producer_name
|
|
8
|
+
|
|
9
|
+
# @param payload [Hash]
|
|
10
|
+
# @param producer [Class]
|
|
11
|
+
def initialize(payload, producer, topic: nil, key: nil, partition_key: nil)
|
|
12
|
+
@payload = payload&.with_indifferent_access
|
|
13
|
+
@producer_name = producer&.name
|
|
14
|
+
@topic = topic
|
|
15
|
+
@key = key
|
|
16
|
+
@partition_key = partition_key
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Add message_id and timestamp default values if they are in the
|
|
20
|
+
# schema and don't already have values.
|
|
21
|
+
# @param fields [Array<String>] existing name fields in the schema.
|
|
22
|
+
def add_fields(fields)
|
|
23
|
+
return if @payload.except(:payload_key, :partition_key).blank?
|
|
24
|
+
|
|
25
|
+
if fields.include?('message_id')
|
|
26
|
+
@payload['message_id'] ||= SecureRandom.uuid
|
|
27
|
+
end
|
|
28
|
+
if fields.include?('timestamp')
|
|
29
|
+
@payload['timestamp'] ||= Time.now.in_time_zone.to_s
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param encoder [Deimos::SchemaBackends::Base]
|
|
34
|
+
def coerce_fields(encoder)
|
|
35
|
+
return if payload.nil?
|
|
36
|
+
|
|
37
|
+
@payload = encoder.coerce(@payload)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
def encoded_hash
|
|
42
|
+
{
|
|
43
|
+
topic: @topic,
|
|
44
|
+
key: @encoded_key,
|
|
45
|
+
partition_key: @partition_key || @encoded_key,
|
|
46
|
+
payload: @encoded_payload,
|
|
47
|
+
metadata: {
|
|
48
|
+
decoded_payload: @payload,
|
|
49
|
+
producer_name: @producer_name
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Hash]
|
|
55
|
+
def to_h
|
|
56
|
+
{
|
|
57
|
+
topic: @topic,
|
|
58
|
+
key: @key,
|
|
59
|
+
partition_key: @partition_key || @key,
|
|
60
|
+
payload: @payload,
|
|
61
|
+
metadata: {
|
|
62
|
+
decoded_payload: @payload,
|
|
63
|
+
producer_name: @producer_name
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @param other [Message]
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
def ==(other)
|
|
71
|
+
self.to_h == other.to_h
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Boolean] True if this message is a tombstone
|
|
75
|
+
def tombstone?
|
|
76
|
+
payload.nil?
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|