deimos-kafka 1.0.0.pre.beta15
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.circleci/config.yml +74 -0
- data/.gitignore +41 -0
- data/.gitmodules +0 -0
- data/.rspec +1 -0
- data/.rubocop.yml +321 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +9 -0
- data/CODE_OF_CONDUCT.md +77 -0
- data/Dockerfile +23 -0
- data/Gemfile +6 -0
- data/Gemfile.lock +165 -0
- data/Guardfile +22 -0
- data/LICENSE.md +195 -0
- data/README.md +742 -0
- data/Rakefile +13 -0
- data/bin/deimos +4 -0
- data/deimos-kafka.gemspec +42 -0
- data/docker-compose.yml +71 -0
- data/docs/DATABASE_BACKEND.md +147 -0
- data/docs/PULL_REQUEST_TEMPLATE.md +34 -0
- data/lib/deimos.rb +134 -0
- data/lib/deimos/active_record_consumer.rb +81 -0
- data/lib/deimos/active_record_producer.rb +64 -0
- data/lib/deimos/avro_data_coder.rb +89 -0
- data/lib/deimos/avro_data_decoder.rb +36 -0
- data/lib/deimos/avro_data_encoder.rb +51 -0
- data/lib/deimos/backends/db.rb +27 -0
- data/lib/deimos/backends/kafka.rb +27 -0
- data/lib/deimos/backends/kafka_async.rb +27 -0
- data/lib/deimos/configuration.rb +88 -0
- data/lib/deimos/consumer.rb +164 -0
- data/lib/deimos/instrumentation.rb +71 -0
- data/lib/deimos/kafka_message.rb +27 -0
- data/lib/deimos/kafka_source.rb +126 -0
- data/lib/deimos/kafka_topic_info.rb +79 -0
- data/lib/deimos/message.rb +74 -0
- data/lib/deimos/metrics/datadog.rb +47 -0
- data/lib/deimos/metrics/mock.rb +39 -0
- data/lib/deimos/metrics/provider.rb +38 -0
- data/lib/deimos/monkey_patches/phobos_cli.rb +35 -0
- data/lib/deimos/monkey_patches/phobos_producer.rb +51 -0
- data/lib/deimos/monkey_patches/ruby_kafka_heartbeat.rb +85 -0
- data/lib/deimos/monkey_patches/schema_store.rb +19 -0
- data/lib/deimos/producer.rb +218 -0
- data/lib/deimos/publish_backend.rb +30 -0
- data/lib/deimos/railtie.rb +8 -0
- data/lib/deimos/schema_coercer.rb +108 -0
- data/lib/deimos/shared_config.rb +59 -0
- data/lib/deimos/test_helpers.rb +356 -0
- data/lib/deimos/tracing/datadog.rb +35 -0
- data/lib/deimos/tracing/mock.rb +40 -0
- data/lib/deimos/tracing/provider.rb +31 -0
- data/lib/deimos/utils/db_producer.rb +95 -0
- data/lib/deimos/utils/executor.rb +117 -0
- data/lib/deimos/utils/inline_consumer.rb +144 -0
- data/lib/deimos/utils/lag_reporter.rb +182 -0
- data/lib/deimos/utils/platform_schema_validation.rb +0 -0
- data/lib/deimos/utils/signal_handler.rb +68 -0
- data/lib/deimos/version.rb +5 -0
- data/lib/generators/deimos/db_backend/templates/migration +24 -0
- data/lib/generators/deimos/db_backend/templates/rails3_migration +30 -0
- data/lib/generators/deimos/db_backend_generator.rb +48 -0
- data/lib/tasks/deimos.rake +17 -0
- data/spec/active_record_consumer_spec.rb +81 -0
- data/spec/active_record_producer_spec.rb +107 -0
- data/spec/avro_data_decoder_spec.rb +18 -0
- data/spec/avro_data_encoder_spec.rb +37 -0
- data/spec/backends/db_spec.rb +35 -0
- data/spec/backends/kafka_async_spec.rb +11 -0
- data/spec/backends/kafka_spec.rb +11 -0
- data/spec/consumer_spec.rb +169 -0
- data/spec/deimos_spec.rb +117 -0
- data/spec/kafka_source_spec.rb +168 -0
- data/spec/kafka_topic_info_spec.rb +88 -0
- data/spec/phobos.bad_db.yml +73 -0
- data/spec/phobos.yml +73 -0
- data/spec/producer_spec.rb +397 -0
- data/spec/publish_backend_spec.rb +10 -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/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/Widget.avsc +27 -0
- data/spec/schemas/com/my-namespace/WidgetTheSecond.avsc +27 -0
- data/spec/spec_helper.rb +207 -0
- data/spec/updateable_schema_store_spec.rb +36 -0
- data/spec/utils/db_producer_spec.rb +208 -0
- data/spec/utils/executor_spec.rb +42 -0
- data/spec/utils/lag_reporter_spec.rb +69 -0
- data/spec/utils/platform_schema_validation_spec.rb +0 -0
- data/spec/utils/signal_handler_spec.rb +16 -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 +452 -0
@@ -0,0 +1,164 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'deimos/avro_data_decoder'
|
4
|
+
require 'deimos/shared_config'
|
5
|
+
require 'phobos/handler'
|
6
|
+
require 'active_support/all'
|
7
|
+
require 'ddtrace'
|
8
|
+
|
9
|
+
# Class to consume messages coming from the pipeline topic
|
10
|
+
# Note: According to the docs, instances of your handler will be created
|
11
|
+
# for every incoming message. This class should be lightweight.
|
12
|
+
module Deimos
|
13
|
+
# Parent consumer class.
|
14
|
+
class Consumer
|
15
|
+
include Phobos::Handler
|
16
|
+
include SharedConfig
|
17
|
+
|
18
|
+
class << self
|
19
|
+
# @return [AvroDataEncoder]
|
20
|
+
def decoder
|
21
|
+
@decoder ||= AvroDataDecoder.new(schema: config[:schema],
|
22
|
+
namespace: config[:namespace])
|
23
|
+
end
|
24
|
+
|
25
|
+
# @return [AvroDataEncoder]
|
26
|
+
def key_decoder
|
27
|
+
@key_decoder ||= AvroDataDecoder.new(schema: config[:key_schema],
|
28
|
+
namespace: config[:namespace])
|
29
|
+
end
|
30
|
+
end
|
31
|
+
|
32
|
+
# :nodoc:
|
33
|
+
def around_consume(payload, metadata)
|
34
|
+
_received_message(payload, metadata)
|
35
|
+
benchmark = Benchmark.measure do
|
36
|
+
_with_error_span(payload, metadata) { yield }
|
37
|
+
end
|
38
|
+
_handle_success(benchmark.real, payload, metadata)
|
39
|
+
end
|
40
|
+
|
41
|
+
# :nodoc:
|
42
|
+
def before_consume(payload, metadata)
|
43
|
+
_with_error_span(payload, metadata) do
|
44
|
+
if self.class.config[:key_schema] || self.class.config[:key_field]
|
45
|
+
metadata[:key] = decode_key(metadata[:key])
|
46
|
+
end
|
47
|
+
self.class.decoder.decode(payload) if payload.present?
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Helper method to decode an Avro-encoded key.
|
52
|
+
# @param key [String]
|
53
|
+
# @return [Object] the decoded key.
|
54
|
+
def decode_key(key)
|
55
|
+
return nil if key.nil?
|
56
|
+
|
57
|
+
config = self.class.config
|
58
|
+
if config[:encode_key] && config[:key_field].nil? &&
|
59
|
+
config[:key_schema].nil?
|
60
|
+
raise 'No key config given - if you are not decoding keys, please use `key_config plain: true`'
|
61
|
+
end
|
62
|
+
|
63
|
+
if config[:key_field]
|
64
|
+
self.class.decoder.decode_key(key, config[:key_field])
|
65
|
+
elsif config[:key_schema]
|
66
|
+
self.class.key_decoder.decode(key, schema: config[:key_schema])
|
67
|
+
else # no encoding
|
68
|
+
key
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# Consume incoming messages.
|
73
|
+
# @param _payload [String]
|
74
|
+
# @param _metadata [Hash]
|
75
|
+
def consume(_payload, _metadata)
|
76
|
+
raise NotImplementedError
|
77
|
+
end
|
78
|
+
|
79
|
+
private
|
80
|
+
|
81
|
+
# @param payload [Hash|String]
|
82
|
+
# @param metadata [Hash]
|
83
|
+
def _with_error_span(payload, metadata)
|
84
|
+
@span = Deimos.config.tracer&.start(
|
85
|
+
'deimos-consumer',
|
86
|
+
resource: self.class.name.gsub('::', '-')
|
87
|
+
)
|
88
|
+
yield
|
89
|
+
rescue StandardError => e
|
90
|
+
_handle_error(e, payload, metadata)
|
91
|
+
ensure
|
92
|
+
Deimos.config.tracer&.finish(@span)
|
93
|
+
end
|
94
|
+
|
95
|
+
def _received_message(payload, metadata)
|
96
|
+
Deimos.config.logger.info(
|
97
|
+
message: 'Got Kafka event',
|
98
|
+
payload: payload,
|
99
|
+
metadata: metadata
|
100
|
+
)
|
101
|
+
Deimos.config.metrics&.increment('handler', tags: %W(
|
102
|
+
status:received
|
103
|
+
topic:#{metadata[:topic]}
|
104
|
+
))
|
105
|
+
end
|
106
|
+
|
107
|
+
# @param exception [Throwable]
|
108
|
+
# @param payload [Hash]
|
109
|
+
# @param metadata [Hash]
|
110
|
+
def _handle_error(exception, payload, metadata)
|
111
|
+
Deimos.config.tracer&.set_error(@span, exception)
|
112
|
+
Deimos.config.metrics&.increment(
|
113
|
+
'handler',
|
114
|
+
tags: %W(
|
115
|
+
status:error
|
116
|
+
topic:#{metadata[:topic]}
|
117
|
+
)
|
118
|
+
)
|
119
|
+
Deimos.config.logger.warn(
|
120
|
+
message: 'Error consuming message',
|
121
|
+
handler: self.class.name,
|
122
|
+
metadata: metadata,
|
123
|
+
data: payload,
|
124
|
+
error_message: exception.message,
|
125
|
+
error: exception.backtrace
|
126
|
+
)
|
127
|
+
raise if Deimos.config.reraise_consumer_errors
|
128
|
+
end
|
129
|
+
|
130
|
+
# @param time_taken [Float]
|
131
|
+
# @param payload [Hash]
|
132
|
+
# @param metadata [Hash]
|
133
|
+
def _handle_success(time_taken, payload, metadata)
|
134
|
+
Deimos.config.metrics&.histogram('handler', time_taken, tags: %W(
|
135
|
+
time:consume
|
136
|
+
topic:#{metadata[:topic]}
|
137
|
+
))
|
138
|
+
Deimos.config.metrics&.increment('handler', tags: %W(
|
139
|
+
status:success
|
140
|
+
topic:#{metadata[:topic]}
|
141
|
+
))
|
142
|
+
Deimos.config.logger.info(
|
143
|
+
message: 'Finished processing Kafka event',
|
144
|
+
payload: payload,
|
145
|
+
time_elapsed: time_taken,
|
146
|
+
metadata: metadata
|
147
|
+
)
|
148
|
+
return if payload.nil? || payload['timestamp'].blank?
|
149
|
+
|
150
|
+
begin
|
151
|
+
time_delayed = Time.now.in_time_zone - payload['timestamp'].to_datetime
|
152
|
+
rescue ArgumentError
|
153
|
+
Deimos.config.logger.info(
|
154
|
+
message: "Error parsing timestamp! #{payload['timestamp']}"
|
155
|
+
)
|
156
|
+
return
|
157
|
+
end
|
158
|
+
Deimos.config.metrics&.histogram('handler', time_delayed, tags: %W(
|
159
|
+
time:time_delayed
|
160
|
+
topic:#{metadata[:topic]}
|
161
|
+
))
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
@@ -0,0 +1,71 @@
|
|
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
|
+
next if batch.empty?
|
50
|
+
|
51
|
+
producer = batch.first.metadata[:producer_name]
|
52
|
+
payloads = batch.map { |m| m.metadata[:decoded_payload] }
|
53
|
+
|
54
|
+
Deimos.metrics&.count('publish_error', payloads.size,
|
55
|
+
tags: %W(topic:#{topic}))
|
56
|
+
Deimos.instrument(
|
57
|
+
'produce_error',
|
58
|
+
producer: producer,
|
59
|
+
topic: topic,
|
60
|
+
exception_object: exception,
|
61
|
+
payloads: payloads
|
62
|
+
)
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
ActiveSupport::Notifications.subscribe('deliver_messages.producer.kafka') do |*args|
|
68
|
+
event = ActiveSupport::Notifications::Event.new(*args)
|
69
|
+
KafkaListener.send_produce_error(event)
|
70
|
+
end
|
71
|
+
end
|
@@ -0,0 +1,27 @@
|
|
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 :message, :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.to_s)
|
15
|
+
end
|
16
|
+
|
17
|
+
# @return [Hash]
|
18
|
+
def phobos_message
|
19
|
+
{
|
20
|
+
payload: self.message,
|
21
|
+
partition_key: self.partition_key,
|
22
|
+
key: self.key,
|
23
|
+
topic: self.topic
|
24
|
+
}
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,126 @@
|
|
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
|
+
included do
|
10
|
+
after_create(:send_kafka_event_on_create)
|
11
|
+
after_update(:send_kafka_event_on_update)
|
12
|
+
after_destroy(:send_kafka_event_on_destroy)
|
13
|
+
end
|
14
|
+
|
15
|
+
# Send the newly created model to Kafka.
|
16
|
+
def send_kafka_event_on_create
|
17
|
+
return unless self.persisted?
|
18
|
+
return unless self.class.kafka_config[:create]
|
19
|
+
|
20
|
+
self.class.kafka_producers.each { |p| p.send_event(self) }
|
21
|
+
end
|
22
|
+
|
23
|
+
# Send the newly updated model to Kafka.
|
24
|
+
def send_kafka_event_on_update
|
25
|
+
return unless self.class.kafka_config[:update]
|
26
|
+
|
27
|
+
producers = self.class.kafka_producers
|
28
|
+
fields = producers.flat_map(&:watched_attributes).uniq
|
29
|
+
fields -= ['updated_at']
|
30
|
+
# Only send an event if a field we care about was changed.
|
31
|
+
any_changes = fields.any? do |field|
|
32
|
+
field_change = self.previous_changes[field]
|
33
|
+
field_change.present? && field_change[0] != field_change[1]
|
34
|
+
end
|
35
|
+
return unless any_changes
|
36
|
+
|
37
|
+
producers.each { |p| p.send_event(self) }
|
38
|
+
end
|
39
|
+
|
40
|
+
# Send a deletion (null payload) event to Kafka.
|
41
|
+
def send_kafka_event_on_destroy
|
42
|
+
return unless self.class.kafka_config[:delete]
|
43
|
+
|
44
|
+
self.class.kafka_producers.each { |p| p.send_event(self.deletion_payload) }
|
45
|
+
end
|
46
|
+
|
47
|
+
# Payload to send after we are destroyed.
|
48
|
+
# @return [Hash]
|
49
|
+
def deletion_payload
|
50
|
+
{ payload_key: self[self.class.primary_key] }
|
51
|
+
end
|
52
|
+
|
53
|
+
# :nodoc:
|
54
|
+
module ClassMethods
|
55
|
+
# @return [Hash]
|
56
|
+
def kafka_config
|
57
|
+
{
|
58
|
+
update: true,
|
59
|
+
delete: true,
|
60
|
+
import: true,
|
61
|
+
create: true
|
62
|
+
}
|
63
|
+
end
|
64
|
+
|
65
|
+
# @return [Array<Deimos::ActiveRecordProducer>] the producers to run.
|
66
|
+
def kafka_producers
|
67
|
+
raise NotImplementedError if self.method(:kafka_producer).
|
68
|
+
owner == Deimos::KafkaSource
|
69
|
+
|
70
|
+
[self.kafka_producer]
|
71
|
+
end
|
72
|
+
|
73
|
+
# Deprecated - use #kafka_producers instead.
|
74
|
+
# @return [Deimos::ActiveRecordProducer] the producer to use.
|
75
|
+
def kafka_producer
|
76
|
+
raise NotImplementedError if self.method(:kafka_producers).
|
77
|
+
owner == Deimos::KafkaSource
|
78
|
+
|
79
|
+
self.kafka_producers.first
|
80
|
+
end
|
81
|
+
|
82
|
+
# This is an internal method, part of the activerecord_import gem. It's
|
83
|
+
# the one that actually does the importing, having already normalized
|
84
|
+
# the inputs (arrays, hashes, records etc.)
|
85
|
+
# Basically we want to first do the import, then reload the records
|
86
|
+
# and send them to Kafka.
|
87
|
+
def import_without_validations_or_callbacks(column_names,
|
88
|
+
array_of_attributes,
|
89
|
+
options={})
|
90
|
+
results = super
|
91
|
+
return unless self.kafka_config[:import]
|
92
|
+
return if array_of_attributes.empty?
|
93
|
+
|
94
|
+
# This will contain an array of hashes, where each hash is the actual
|
95
|
+
# attribute hash that created the object.
|
96
|
+
ids =
|
97
|
+
ids = if results.is_a?(Array)
|
98
|
+
results[1]
|
99
|
+
elsif results.respond_to?(:ids)
|
100
|
+
results.ids
|
101
|
+
else
|
102
|
+
[]
|
103
|
+
end
|
104
|
+
if ids.blank?
|
105
|
+
# re-fill IDs based on what was just entered into the DB.
|
106
|
+
if self.connection.adapter_name.downcase =~ /sqlite/
|
107
|
+
last_id = self.connection.select_value('select last_insert_rowid()')
|
108
|
+
ids = ((last_id - array_of_attributes.size + 1)..last_id).to_a
|
109
|
+
else # mysql
|
110
|
+
last_id = self.connection.select_value('select LAST_INSERT_ID()')
|
111
|
+
ids = (last_id..(last_id + array_of_attributes.size)).to_a
|
112
|
+
end
|
113
|
+
end
|
114
|
+
array_of_hashes = []
|
115
|
+
array_of_attributes.each_with_index do |array, i|
|
116
|
+
hash = column_names.zip(array).to_h.with_indifferent_access
|
117
|
+
hash[self.primary_key] = ids[i] if hash[self.primary_key].blank?
|
118
|
+
array_of_hashes << hash
|
119
|
+
end
|
120
|
+
|
121
|
+
self.kafka_producers.each { |p| p.send_events(array_of_hashes) }
|
122
|
+
results
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
@@ -0,0 +1,79 @@
|
|
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)
|
17
|
+
rescue ActiveRecord::RecordNotUnique # rubocop:disable Lint/HandleExceptions
|
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, locked_at: nil, error: false, retries: 0)
|
56
|
+
end
|
57
|
+
|
58
|
+
# The producer calls this if it gets an error sending messages. This
|
59
|
+
# essentially locks down this topic for 1 minute (for all producers)
|
60
|
+
# and allows the caller to continue to the next topic.
|
61
|
+
# @param topic [String]
|
62
|
+
# @param lock_id [String]
|
63
|
+
def register_error(topic, lock_id)
|
64
|
+
record = self.where(topic: topic, locked_by: lock_id).last
|
65
|
+
record.update(locked_by: nil, locked_at: Time.zone.now, error: true,
|
66
|
+
retries: record.retries + 1)
|
67
|
+
end
|
68
|
+
|
69
|
+
# Update the locked_at timestamp to indicate that the producer is still
|
70
|
+
# working on those messages and to continue.
|
71
|
+
# @param topic [String]
|
72
|
+
# @param lock_id [String]
|
73
|
+
def heartbeat(topic, lock_id)
|
74
|
+
self.where(topic: topic, locked_by: lock_id).
|
75
|
+
update_all(locked_at: Time.zone.now)
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|