deimos-ruby 1.8.0.pre.beta1 → 1.8.1.pre.beta4
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/.rubocop.yml +8 -4
- data/CHANGELOG.md +42 -0
- data/Gemfile.lock +101 -73
- data/README.md +78 -1
- data/deimos-ruby.gemspec +2 -2
- data/lib/deimos.rb +4 -3
- data/lib/deimos/consume/batch_consumption.rb +2 -0
- data/lib/deimos/consume/message_consumption.rb +1 -0
- data/lib/deimos/instrumentation.rb +10 -5
- data/lib/deimos/kafka_topic_info.rb +21 -2
- data/lib/deimos/schema_backends/avro_base.rb +33 -1
- data/lib/deimos/schema_backends/avro_schema_coercer.rb +30 -9
- data/lib/deimos/schema_backends/base.rb +21 -2
- data/lib/deimos/utils/db_producer.rb +57 -19
- data/lib/deimos/utils/schema_controller_mixin.rb +111 -0
- data/lib/deimos/version.rb +1 -1
- 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 +1 -0
- data/lib/generators/deimos/db_backend/templates/rails3_migration +1 -0
- data/spec/batch_consumer_spec.rb +1 -0
- data/spec/generators/active_record_generator_spec.rb +56 -0
- data/spec/kafka_listener_spec.rb +54 -0
- data/spec/kafka_topic_info_spec.rb +39 -16
- data/spec/producer_spec.rb +36 -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/request/Index.avsc +11 -0
- data/spec/schemas/com/my-namespace/request/UpdateRequest.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 +7 -0
- data/spec/utils/db_producer_spec.rb +84 -10
- data/spec/utils/schema_controller_mixin_spec.rb +68 -0
- metadata +40 -24
data/lib/deimos/version.rb
CHANGED
@@ -0,0 +1,28 @@
|
|
1
|
+
class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
|
2
|
+
def up
|
3
|
+
if table_exists?(:<%= table_name %>)
|
4
|
+
warn "<%= table_name %> already exists, exiting"
|
5
|
+
return
|
6
|
+
end
|
7
|
+
create_table :<%= table_name %> do |t|
|
8
|
+
<%- fields.each do |key| -%>
|
9
|
+
<%- next if %w(id message_id timestamp).include?(key.name) -%>
|
10
|
+
<%- sql_type = schema_base.sql_type(key)
|
11
|
+
if %w(record array map).include?(sql_type)
|
12
|
+
conn = ActiveRecord::Base.connection
|
13
|
+
sql_type = conn.respond_to?(:supports_json?) && conn.supports_json? ? :json : :string
|
14
|
+
end
|
15
|
+
-%>
|
16
|
+
t.<%= sql_type %> :<%= key.name %>
|
17
|
+
<%- end -%>
|
18
|
+
end
|
19
|
+
|
20
|
+
# TODO add indexes as necessary
|
21
|
+
end
|
22
|
+
|
23
|
+
def down
|
24
|
+
return unless table_exists?(:<%= table_name %>)
|
25
|
+
drop_table :<%= table_name %>
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
@@ -0,0 +1,79 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'rails/generators'
|
4
|
+
require 'rails/generators/active_record/migration'
|
5
|
+
require 'rails/version'
|
6
|
+
|
7
|
+
# Generates a new consumer.
|
8
|
+
module Deimos
|
9
|
+
module Generators
|
10
|
+
# Generator for ActiveRecord model and migration.
|
11
|
+
class ActiveRecordGenerator < Rails::Generators::Base
|
12
|
+
include Rails::Generators::Migration
|
13
|
+
if Rails.version < '4'
|
14
|
+
extend(ActiveRecord::Generators::Migration)
|
15
|
+
else
|
16
|
+
include ActiveRecord::Generators::Migration
|
17
|
+
end
|
18
|
+
source_root File.expand_path('active_record/templates', __dir__)
|
19
|
+
|
20
|
+
argument :table_name, desc: 'The table to create.', required: true
|
21
|
+
argument :full_schema, desc: 'The fully qualified schema name.', required: true
|
22
|
+
|
23
|
+
no_commands do
|
24
|
+
|
25
|
+
# @return [String]
|
26
|
+
def db_migrate_path
|
27
|
+
if defined?(Rails.application) && Rails.application
|
28
|
+
paths = Rails.application.config.paths['db/migrate']
|
29
|
+
paths.respond_to?(:to_ary) ? paths.to_ary.first : paths.to_a.first
|
30
|
+
else
|
31
|
+
'db/migrate'
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# @return [String]
|
36
|
+
def migration_version
|
37
|
+
"[#{ActiveRecord::Migration.current_version}]"
|
38
|
+
rescue StandardError
|
39
|
+
''
|
40
|
+
end
|
41
|
+
|
42
|
+
# @return [String]
|
43
|
+
def table_class
|
44
|
+
self.table_name.classify
|
45
|
+
end
|
46
|
+
|
47
|
+
# @return [String]
|
48
|
+
def schema
|
49
|
+
last_dot = self.full_schema.rindex('.')
|
50
|
+
self.full_schema[last_dot + 1..-1]
|
51
|
+
end
|
52
|
+
|
53
|
+
# @return [String]
|
54
|
+
def namespace
|
55
|
+
last_dot = self.full_schema.rindex('.')
|
56
|
+
self.full_schema[0...last_dot]
|
57
|
+
end
|
58
|
+
|
59
|
+
# @return [Deimos::SchemaBackends::Base]
|
60
|
+
def schema_base
|
61
|
+
@schema_base ||= Deimos.schema_backend_class.new(schema: schema, namespace: namespace)
|
62
|
+
end
|
63
|
+
|
64
|
+
# @return [Array<SchemaField>]
|
65
|
+
def fields
|
66
|
+
schema_base.schema_fields
|
67
|
+
end
|
68
|
+
|
69
|
+
end
|
70
|
+
|
71
|
+
desc 'Generate migration for a table based on an existing schema.'
|
72
|
+
# :nodoc:
|
73
|
+
def generate
|
74
|
+
migration_template('migration.rb', "db/migrate/create_#{table_name.underscore}.rb")
|
75
|
+
template('model.rb', "app/models/#{table_name.underscore}.rb")
|
76
|
+
end
|
77
|
+
end
|
78
|
+
end
|
79
|
+
end
|
@@ -16,6 +16,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
|
|
16
16
|
t.datetime :locked_at
|
17
17
|
t.boolean :error, null: false, default: false
|
18
18
|
t.integer :retries, null: false, default: 0
|
19
|
+
t.datetime :last_processed_at
|
19
20
|
end
|
20
21
|
add_index :kafka_topic_info, :topic, unique: true
|
21
22
|
add_index :kafka_topic_info, [:locked_by, :error]
|
@@ -16,6 +16,7 @@ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version
|
|
16
16
|
t.datetime :locked_at
|
17
17
|
t.boolean :error, null: false, default: false
|
18
18
|
t.integer :retries, null: false, default: 0
|
19
|
+
t.datetime :last_processed_at
|
19
20
|
end
|
20
21
|
add_index :kafka_topic_info, :topic, unique: true
|
21
22
|
add_index :kafka_topic_info, [:locked_by, :error]
|
data/spec/batch_consumer_spec.rb
CHANGED
@@ -111,6 +111,7 @@ module ConsumerTest
|
|
111
111
|
test_consume_batch('my_batch_consume_topic', batch, keys: keys) do |_received, metadata|
|
112
112
|
# Mock decode_key extracts the value of the first field as the key
|
113
113
|
expect(metadata[:keys]).to eq(%w(foo bar))
|
114
|
+
expect(metadata[:first_offset]).to eq(1)
|
114
115
|
end
|
115
116
|
end
|
116
117
|
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'generators/deimos/active_record_generator'
|
4
|
+
|
5
|
+
RSpec.describe Deimos::Generators::ActiveRecordGenerator do
|
6
|
+
|
7
|
+
after(:each) do
|
8
|
+
FileUtils.rm_rf('db') if File.exist?('db')
|
9
|
+
FileUtils.rm_rf('app') if File.exist?('app')
|
10
|
+
end
|
11
|
+
|
12
|
+
it 'should generate a migration' do
|
13
|
+
expect(Dir['db/migrate/*.rb']).to be_empty
|
14
|
+
expect(Dir['app/models/*.rb']).to be_empty
|
15
|
+
described_class.start(['generated_table', 'com.my-namespace.Generated'])
|
16
|
+
files = Dir['db/migrate/*.rb']
|
17
|
+
expect(files.length).to eq(1)
|
18
|
+
results = <<~MIGRATION
|
19
|
+
class CreateGeneratedTable < ActiveRecord::Migration[6.0]
|
20
|
+
def up
|
21
|
+
if table_exists?(:generated_table)
|
22
|
+
warn "generated_table already exists, exiting"
|
23
|
+
return
|
24
|
+
end
|
25
|
+
create_table :generated_table do |t|
|
26
|
+
t.string :a_string
|
27
|
+
t.integer :a_int
|
28
|
+
t.bigint :a_long
|
29
|
+
t.float :a_float
|
30
|
+
t.float :a_double
|
31
|
+
t.string :an_enum
|
32
|
+
t.json :an_array
|
33
|
+
t.json :a_map
|
34
|
+
t.json :a_record
|
35
|
+
end
|
36
|
+
|
37
|
+
# TODO add indexes as necessary
|
38
|
+
end
|
39
|
+
|
40
|
+
def down
|
41
|
+
return unless table_exists?(:generated_table)
|
42
|
+
drop_table :generated_table
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
MIGRATION
|
47
|
+
expect(File.read(files[0])).to eq(results)
|
48
|
+
model = <<~MODEL
|
49
|
+
class GeneratedTable < ApplicationRecord
|
50
|
+
enum an_enum: {sym1: 'sym1', sym2: 'sym2'}
|
51
|
+
end
|
52
|
+
MODEL
|
53
|
+
expect(File.read('app/models/generated_table.rb')).to eq(model)
|
54
|
+
end
|
55
|
+
|
56
|
+
end
|
@@ -0,0 +1,54 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
describe Deimos::KafkaListener do
|
4
|
+
include_context 'with widgets'
|
5
|
+
|
6
|
+
prepend_before(:each) do
|
7
|
+
producer_class = Class.new(Deimos::Producer) do
|
8
|
+
schema 'MySchema'
|
9
|
+
namespace 'com.my-namespace'
|
10
|
+
topic 'my-topic'
|
11
|
+
key_config none: true
|
12
|
+
end
|
13
|
+
stub_const('MyProducer', producer_class)
|
14
|
+
end
|
15
|
+
|
16
|
+
before(:each) do
|
17
|
+
Deimos.configure do |c|
|
18
|
+
c.producers.backend = :kafka
|
19
|
+
c.schema.backend = :avro_local
|
20
|
+
end
|
21
|
+
allow_any_instance_of(Kafka::Cluster).to receive(:add_target_topics)
|
22
|
+
allow_any_instance_of(Kafka::Cluster).to receive(:partitions_for).
|
23
|
+
and_raise(Kafka::Error)
|
24
|
+
end
|
25
|
+
|
26
|
+
describe '.send_produce_error' do
|
27
|
+
let(:payloads) do
|
28
|
+
[{ 'test_id' => 'foo', 'some_int' => 123 },
|
29
|
+
{ 'test_id' => 'bar', 'some_int' => 124 }]
|
30
|
+
end
|
31
|
+
|
32
|
+
it 'should listen to publishing errors and republish as Deimos events' do
|
33
|
+
Deimos.subscribe('produce_error') do |event|
|
34
|
+
expect(event.payload).to include(
|
35
|
+
producer: MyProducer,
|
36
|
+
topic: 'my-topic',
|
37
|
+
payloads: payloads
|
38
|
+
)
|
39
|
+
end
|
40
|
+
expect(Deimos.config.metrics).to receive(:increment).
|
41
|
+
with('publish_error', tags: %w(topic:my-topic), by: 2)
|
42
|
+
expect { MyProducer.publish_list(payloads) }.to raise_error(Kafka::DeliveryFailed)
|
43
|
+
end
|
44
|
+
|
45
|
+
it 'should not send any notifications when producer is not found' do
|
46
|
+
Deimos.subscribe('produce_error') do |_|
|
47
|
+
raise 'OH NOES'
|
48
|
+
end
|
49
|
+
allow(Deimos::Producer).to receive(:descendants).and_return([])
|
50
|
+
expect(Deimos.config.metrics).not_to receive(:increment).with('publish_error', anything)
|
51
|
+
expect { MyProducer.publish_list(payloads) }.to raise_error(Kafka::DeliveryFailed)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
@@ -37,22 +37,45 @@ each_db_config(Deimos::KafkaTopicInfo) do
|
|
37
37
|
end
|
38
38
|
|
39
39
|
specify '#clear_lock' do
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
40
|
+
freeze_time do
|
41
|
+
Deimos::KafkaTopicInfo.create!(topic: 'my-topic', locked_by: 'abc',
|
42
|
+
locked_at: 10.seconds.ago, error: true, retries: 1,
|
43
|
+
last_processed_at: 20.seconds.ago)
|
44
|
+
Deimos::KafkaTopicInfo.create!(topic: 'my-topic2', locked_by: 'def',
|
45
|
+
locked_at: 10.seconds.ago, error: true, retries: 1,
|
46
|
+
last_processed_at: 20.seconds.ago)
|
47
|
+
Deimos::KafkaTopicInfo.clear_lock('my-topic', 'abc')
|
48
|
+
expect(Deimos::KafkaTopicInfo.count).to eq(2)
|
49
|
+
record = Deimos::KafkaTopicInfo.first
|
50
|
+
expect(record.locked_by).to eq(nil)
|
51
|
+
expect(record.locked_at).to eq(nil)
|
52
|
+
expect(record.error).to eq(false)
|
53
|
+
expect(record.retries).to eq(0)
|
54
|
+
expect(record.last_processed_at.to_s).to eq(Time.zone.now.to_s)
|
55
|
+
record = Deimos::KafkaTopicInfo.last
|
56
|
+
expect(record.locked_by).not_to eq(nil)
|
57
|
+
expect(record.locked_at).not_to eq(nil)
|
58
|
+
expect(record.error).not_to eq(false)
|
59
|
+
expect(record.retries).not_to eq(0)
|
60
|
+
expect(record.last_processed_at.to_s).to eq(20.seconds.ago.to_s)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
specify '#ping_empty_topics' do
|
65
|
+
freeze_time do
|
66
|
+
old_time = 1.hour.ago.to_s
|
67
|
+
t1 = Deimos::KafkaTopicInfo.create!(topic: 'topic1', last_processed_at: old_time)
|
68
|
+
t2 = Deimos::KafkaTopicInfo.create!(topic: 'topic2', last_processed_at: old_time)
|
69
|
+
t3 = Deimos::KafkaTopicInfo.create!(topic: 'topic3', last_processed_at: old_time,
|
70
|
+
locked_by: 'me', locked_at: 1.minute.ago)
|
71
|
+
|
72
|
+
expect(Deimos::KafkaTopicInfo.count).to eq(3)
|
73
|
+
Deimos::KafkaTopicInfo.all.each { |t| expect(t.last_processed_at.to_s).to eq(old_time) }
|
74
|
+
Deimos::KafkaTopicInfo.ping_empty_topics(%w(topic1))
|
75
|
+
expect(t1.reload.last_processed_at.to_s).to eq(old_time) # was passed as an exception
|
76
|
+
expect(t2.reload.last_processed_at.to_s).to eq(Time.zone.now.to_s)
|
77
|
+
expect(t3.reload.last_processed_at.to_s).to eq(old_time) # is locked
|
78
|
+
end
|
56
79
|
end
|
57
80
|
|
58
81
|
specify '#register_error' do
|
data/spec/producer_spec.rb
CHANGED
@@ -41,6 +41,14 @@ module ProducerTest
|
|
41
41
|
end
|
42
42
|
stub_const('MyNoKeyProducer', producer_class)
|
43
43
|
|
44
|
+
producer_class = Class.new(Deimos::Producer) do
|
45
|
+
schema 'MyNestedSchema'
|
46
|
+
namespace 'com.my-namespace'
|
47
|
+
topic 'my-topic'
|
48
|
+
key_config field: 'test_id'
|
49
|
+
end
|
50
|
+
stub_const('MyNestedSchemaProducer', producer_class)
|
51
|
+
|
44
52
|
producer_class = Class.new(Deimos::Producer) do
|
45
53
|
schema 'MySchema'
|
46
54
|
namespace 'com.my-namespace'
|
@@ -233,6 +241,34 @@ module ProducerTest
|
|
233
241
|
)
|
234
242
|
end
|
235
243
|
|
244
|
+
it 'should properly encode and coerce values with a nested record' do
|
245
|
+
expect(MyNestedSchemaProducer.encoder).to receive(:encode_key).with('test_id', 'foo', topic: 'my-topic-key')
|
246
|
+
MyNestedSchemaProducer.publish(
|
247
|
+
'test_id' => 'foo',
|
248
|
+
'test_float' => BigDecimal('123.456'),
|
249
|
+
'test_array' => ['1'],
|
250
|
+
'some_nested_record' => {
|
251
|
+
'some_int' => 123,
|
252
|
+
'some_float' => BigDecimal('456.789'),
|
253
|
+
'some_string' => '123',
|
254
|
+
'some_optional_int' => nil
|
255
|
+
},
|
256
|
+
'some_optional_record' => nil
|
257
|
+
)
|
258
|
+
expect(MyNestedSchemaProducer.topic).to have_sent(
|
259
|
+
'test_id' => 'foo',
|
260
|
+
'test_float' => 123.456,
|
261
|
+
'test_array' => ['1'],
|
262
|
+
'some_nested_record' => {
|
263
|
+
'some_int' => 123,
|
264
|
+
'some_float' => 456.789,
|
265
|
+
'some_string' => '123',
|
266
|
+
'some_optional_int' => nil
|
267
|
+
},
|
268
|
+
'some_optional_record' => nil
|
269
|
+
)
|
270
|
+
end
|
271
|
+
|
236
272
|
it 'should error with nothing set' do
|
237
273
|
expect {
|
238
274
|
MyErrorProducer.publish_list(
|
@@ -0,0 +1,71 @@
|
|
1
|
+
{
|
2
|
+
"namespace": "com.my-namespace",
|
3
|
+
"name": "Generated",
|
4
|
+
"type": "record",
|
5
|
+
"doc": "Test schema",
|
6
|
+
"fields": [
|
7
|
+
{
|
8
|
+
"name": "a_string",
|
9
|
+
"type": "string"
|
10
|
+
},
|
11
|
+
{
|
12
|
+
"name": "a_int",
|
13
|
+
"type": "int"
|
14
|
+
},
|
15
|
+
{
|
16
|
+
"name": "a_long",
|
17
|
+
"type": "long"
|
18
|
+
},
|
19
|
+
{
|
20
|
+
"name": "a_float",
|
21
|
+
"type": "float"
|
22
|
+
},
|
23
|
+
{
|
24
|
+
"name": "a_double",
|
25
|
+
"type": "double"
|
26
|
+
},
|
27
|
+
{
|
28
|
+
"name": "an_enum",
|
29
|
+
"type": {
|
30
|
+
"type": "enum",
|
31
|
+
"name": "AnEnum",
|
32
|
+
"symbols": ["sym1", "sym2"]
|
33
|
+
}
|
34
|
+
},
|
35
|
+
{
|
36
|
+
"name": "an_array",
|
37
|
+
"type": {
|
38
|
+
"type": "array",
|
39
|
+
"items": "int"
|
40
|
+
}
|
41
|
+
},
|
42
|
+
{
|
43
|
+
"name": "a_map",
|
44
|
+
"type": {
|
45
|
+
"type": "map",
|
46
|
+
"values": "string"
|
47
|
+
}
|
48
|
+
},
|
49
|
+
{
|
50
|
+
"name": "timestamp",
|
51
|
+
"type": "string"
|
52
|
+
},
|
53
|
+
{
|
54
|
+
"name": "message_id",
|
55
|
+
"type": "string"
|
56
|
+
},
|
57
|
+
{
|
58
|
+
"name": "a_record",
|
59
|
+
"type": {
|
60
|
+
"type": "record",
|
61
|
+
"name": "ARecord",
|
62
|
+
"fields": [
|
63
|
+
{
|
64
|
+
"name": "a_record_field",
|
65
|
+
"type": "string"
|
66
|
+
}
|
67
|
+
]
|
68
|
+
}
|
69
|
+
}
|
70
|
+
]
|
71
|
+
}
|