deimos-ruby 1.6.1 → 1.8.0.pre.beta1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (67) hide show
  1. checksums.yaml +4 -4
  2. data/.circleci/config.yml +9 -0
  3. data/.rubocop.yml +15 -13
  4. data/.ruby-version +1 -1
  5. data/CHANGELOG.md +30 -0
  6. data/Gemfile.lock +87 -80
  7. data/README.md +139 -15
  8. data/Rakefile +1 -1
  9. data/deimos-ruby.gemspec +3 -2
  10. data/docs/ARCHITECTURE.md +144 -0
  11. data/docs/CONFIGURATION.md +27 -0
  12. data/lib/deimos.rb +7 -6
  13. data/lib/deimos/active_record_consume/batch_consumption.rb +159 -0
  14. data/lib/deimos/active_record_consume/batch_slicer.rb +27 -0
  15. data/lib/deimos/active_record_consume/message_consumption.rb +58 -0
  16. data/lib/deimos/active_record_consume/schema_model_converter.rb +52 -0
  17. data/lib/deimos/active_record_consumer.rb +33 -75
  18. data/lib/deimos/active_record_producer.rb +23 -0
  19. data/lib/deimos/batch_consumer.rb +2 -140
  20. data/lib/deimos/config/configuration.rb +28 -10
  21. data/lib/deimos/consume/batch_consumption.rb +148 -0
  22. data/lib/deimos/consume/message_consumption.rb +93 -0
  23. data/lib/deimos/consumer.rb +79 -69
  24. data/lib/deimos/kafka_message.rb +1 -1
  25. data/lib/deimos/kafka_source.rb +29 -23
  26. data/lib/deimos/kafka_topic_info.rb +1 -1
  27. data/lib/deimos/message.rb +6 -1
  28. data/lib/deimos/metrics/provider.rb +0 -2
  29. data/lib/deimos/poll_info.rb +9 -0
  30. data/lib/deimos/tracing/provider.rb +0 -2
  31. data/lib/deimos/utils/db_poller.rb +149 -0
  32. data/lib/deimos/utils/db_producer.rb +8 -3
  33. data/lib/deimos/utils/deadlock_retry.rb +68 -0
  34. data/lib/deimos/utils/lag_reporter.rb +19 -26
  35. data/lib/deimos/version.rb +1 -1
  36. data/lib/generators/deimos/db_poller/templates/migration +11 -0
  37. data/lib/generators/deimos/db_poller/templates/rails3_migration +16 -0
  38. data/lib/generators/deimos/db_poller_generator.rb +48 -0
  39. data/lib/tasks/deimos.rake +7 -0
  40. data/spec/active_record_batch_consumer_spec.rb +481 -0
  41. data/spec/active_record_consume/batch_slicer_spec.rb +42 -0
  42. data/spec/active_record_consume/schema_model_converter_spec.rb +105 -0
  43. data/spec/active_record_consumer_spec.rb +22 -11
  44. data/spec/active_record_producer_spec.rb +66 -88
  45. data/spec/batch_consumer_spec.rb +23 -7
  46. data/spec/config/configuration_spec.rb +4 -0
  47. data/spec/consumer_spec.rb +8 -8
  48. data/spec/deimos_spec.rb +57 -49
  49. data/spec/handlers/my_batch_consumer.rb +6 -1
  50. data/spec/handlers/my_consumer.rb +6 -1
  51. data/spec/kafka_source_spec.rb +53 -0
  52. data/spec/message_spec.rb +19 -0
  53. data/spec/producer_spec.rb +3 -3
  54. data/spec/rake_spec.rb +1 -1
  55. data/spec/schemas/com/my-namespace/MySchemaCompound-key.avsc +18 -0
  56. data/spec/schemas/com/my-namespace/Wibble.avsc +43 -0
  57. data/spec/spec_helper.rb +61 -6
  58. data/spec/utils/db_poller_spec.rb +320 -0
  59. data/spec/utils/deadlock_retry_spec.rb +74 -0
  60. data/spec/utils/lag_reporter_spec.rb +29 -22
  61. metadata +61 -20
  62. data/lib/deimos/base_consumer.rb +0 -104
  63. data/lib/deimos/utils/executor.rb +0 -124
  64. data/lib/deimos/utils/platform_schema_validation.rb +0 -0
  65. data/lib/deimos/utils/signal_handler.rb +0 -68
  66. data/spec/utils/executor_spec.rb +0 -53
  67. data/spec/utils/signal_handler_spec.rb +0 -16
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Deimos
4
- VERSION = '1.6.1'
4
+ VERSION = '1.8.0-beta1'
5
5
  end
@@ -0,0 +1,11 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :deimos_poll_info, force: true do |t|
4
+ t.string :producer, null: false
5
+ t.datetime :last_sent
6
+ t.bigint :last_sent_id
7
+ end
8
+
9
+ add_index :deimos_poll_info, [:producer]
10
+ end
11
+ end
@@ -0,0 +1,16 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def self.up
3
+ create_table :deimos_poll_info, force: true do |t|
4
+ t.string :producer, null: false
5
+ t.datetime :last_sent
6
+ t.bigint :last_sent_id
7
+ end
8
+
9
+ add_index :deimos_poll_info, [:producer]
10
+ end
11
+
12
+ def self.down
13
+ drop_table :deimos_poll_info
14
+ end
15
+
16
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails/generators'
4
+ require 'rails/generators/active_record/migration'
5
+
6
+ module Deimos
7
+ module Generators
8
+ # Generate the database backend migration.
9
+ class DbPollerGenerator < Rails::Generators::Base
10
+ include Rails::Generators::Migration
11
+ if Rails.version < '4'
12
+ extend(ActiveRecord::Generators::Migration)
13
+ else
14
+ include ActiveRecord::Generators::Migration
15
+ end
16
+ source_root File.expand_path('db_poller/templates', __dir__)
17
+ desc 'Add migrations for the database poller'
18
+
19
+ # @return [String]
20
+ def migration_version
21
+ "[#{ActiveRecord::Migration.current_version}]"
22
+ rescue StandardError
23
+ ''
24
+ end
25
+
26
+ # @return [String]
27
+ def db_migrate_path
28
+ if defined?(Rails.application) && Rails.application
29
+ paths = Rails.application.config.paths['db/migrate']
30
+ paths.respond_to?(:to_ary) ? paths.to_ary.first : paths.to_a.first
31
+ else
32
+ 'db/migrate'
33
+ end
34
+ end
35
+
36
+ # Main method to create all the necessary files
37
+ def generate
38
+ if Rails.version < '4'
39
+ migration_template('rails3_migration',
40
+ "#{db_migrate_path}/create_db_poller.rb")
41
+ else
42
+ migration_template('migration',
43
+ "#{db_migrate_path}/create_db_poller.rb")
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -24,4 +24,11 @@ namespace :deimos do
24
24
  Deimos.start_db_backend!(thread_count: thread_count)
25
25
  end
26
26
 
27
+ task db_poller: :environment do
28
+ ENV['DEIMOS_RAKE_TASK'] = 'true'
29
+ STDOUT.sync = true
30
+ Rails.logger.info('Running deimos:db_poller rake task.')
31
+ Deimos::Utils::DbPoller.start!
32
+ end
33
+
27
34
  end
@@ -0,0 +1,481 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Wrapped in a module to prevent class leakage
4
+ module ActiveRecordBatchConsumerTest
5
+ describe Deimos::ActiveRecordConsumer do
6
+ # Create ActiveRecord table and model
7
+ before(:all) do
8
+ ActiveRecord::Base.connection.create_table(:widgets, force: true) do |t|
9
+ t.string(:test_id)
10
+ t.string(:part_one)
11
+ t.string(:part_two)
12
+ t.integer(:some_int)
13
+ t.boolean(:deleted, default: false)
14
+ t.timestamps
15
+
16
+ t.index(%i(part_one part_two), unique: true)
17
+ end
18
+
19
+ # Sample model
20
+ class Widget < ActiveRecord::Base
21
+ validates :test_id, presence: true
22
+
23
+ default_scope -> { where(deleted: false) }
24
+ end
25
+
26
+ Widget.reset_column_information
27
+ end
28
+
29
+ after(:all) do
30
+ ActiveRecord::Base.connection.drop_table(:widgets)
31
+ end
32
+
33
+ prepend_before(:each) do
34
+ stub_const('MyBatchConsumer', consumer_class)
35
+ end
36
+
37
+ around(:each) do |ex|
38
+ # Set and freeze example time
39
+ travel_to start do
40
+ ex.run
41
+ end
42
+ end
43
+
44
+ # Default starting time
45
+ let(:start) { Time.zone.local(2019, 1, 1, 10, 30, 0) }
46
+
47
+ # Basic uncompacted consumer
48
+ let(:consumer_class) do
49
+ Class.new(described_class) do
50
+ schema 'MySchema'
51
+ namespace 'com.my-namespace'
52
+ key_config plain: true
53
+ record_class Widget
54
+ compacted false
55
+ end
56
+ end
57
+
58
+ # Helper to get all instances, ignoring default scopes
59
+ def all_widgets
60
+ Widget.unscoped.all
61
+ end
62
+
63
+ # Helper to publish a list of messages and call the consumer
64
+ def publish_batch(messages)
65
+ keys = messages.map { |m| m[:key] }
66
+ payloads = messages.map { |m| m[:payload] }
67
+
68
+ test_consume_batch(MyBatchConsumer, payloads, keys: keys, call_original: true)
69
+ end
70
+
71
+ it 'should handle an empty batch' do
72
+ expect { publish_batch([]) }.not_to raise_error
73
+ end
74
+
75
+ it 'should create records from a batch' do
76
+ publish_batch(
77
+ [
78
+ { key: 1,
79
+ payload: { test_id: 'abc', some_int: 3 } },
80
+ { key: 2,
81
+ payload: { test_id: 'def', some_int: 4 } }
82
+ ]
83
+ )
84
+
85
+ expect(all_widgets).
86
+ to match_array(
87
+ [
88
+ have_attributes(id: 1, test_id: 'abc', some_int: 3, updated_at: start, created_at: start),
89
+ have_attributes(id: 2, test_id: 'def', some_int: 4, updated_at: start, created_at: start)
90
+ ]
91
+ )
92
+ end
93
+
94
+ it 'should handle deleting a record that doesn\'t exist' do
95
+ publish_batch(
96
+ [
97
+ { key: 1,
98
+ payload: nil }
99
+ ]
100
+ )
101
+
102
+ expect(all_widgets).to be_empty
103
+ end
104
+
105
+ it 'should handle an update, followed by a delete in the correct order' do
106
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
107
+
108
+ publish_batch(
109
+ [
110
+ { key: 1,
111
+ payload: { test_id: 'abc', some_int: 3 } },
112
+ { key: 1,
113
+ payload: nil }
114
+ ]
115
+ )
116
+
117
+ expect(all_widgets).to be_empty
118
+ end
119
+
120
+ it 'should handle a delete, followed by an update in the correct order' do
121
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
122
+
123
+ travel 1.day
124
+
125
+ publish_batch(
126
+ [
127
+ { key: 1,
128
+ payload: nil },
129
+ { key: 1,
130
+ payload: { test_id: 'abc', some_int: 3 } }
131
+ ]
132
+ )
133
+
134
+ expect(all_widgets).
135
+ to match_array(
136
+ [
137
+ have_attributes(id: 1, test_id: 'abc', some_int: 3, updated_at: Time.zone.now, created_at: Time.zone.now)
138
+ ]
139
+ )
140
+ end
141
+
142
+ it 'should handle a double update' do
143
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
144
+
145
+ travel 1.day
146
+
147
+ publish_batch(
148
+ [
149
+ { key: 1,
150
+ payload: { test_id: 'def', some_int: 3 } },
151
+ { key: 1,
152
+ payload: { test_id: 'ghi', some_int: 4 } }
153
+ ]
154
+ )
155
+
156
+ expect(all_widgets).
157
+ to match_array(
158
+ [
159
+ have_attributes(id: 1, test_id: 'ghi', some_int: 4, updated_at: Time.zone.now, created_at: start)
160
+ ]
161
+ )
162
+ end
163
+
164
+ it 'should handle a double deletion' do
165
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
166
+
167
+ publish_batch(
168
+ [
169
+ { key: 1,
170
+ payload: nil },
171
+ { key: 1,
172
+ payload: nil }
173
+ ]
174
+ )
175
+
176
+ expect(all_widgets).to be_empty
177
+ end
178
+
179
+ it 'should ignore default scopes' do
180
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2, deleted: true)
181
+ Widget.create!(id: 2, test_id: 'def', some_int: 3, deleted: true)
182
+
183
+ publish_batch(
184
+ [
185
+ { key: 1,
186
+ payload: nil },
187
+ { key: 2,
188
+ payload: { test_id: 'def', some_int: 5 } }
189
+ ]
190
+ )
191
+
192
+ expect(all_widgets).
193
+ to match_array(
194
+ [
195
+ have_attributes(id: 2, test_id: 'def', some_int: 5)
196
+ ]
197
+ )
198
+ end
199
+
200
+ describe 'compacted mode' do
201
+ # Create a compacted consumer
202
+ let(:consumer_class) do
203
+ Class.new(described_class) do
204
+ schema 'MySchema'
205
+ namespace 'com.my-namespace'
206
+ key_config plain: true
207
+ record_class Widget
208
+
209
+ # :no-doc:
210
+ def deleted_query(_records)
211
+ raise 'Should not have anything to delete!'
212
+ end
213
+ end
214
+ end
215
+
216
+ it 'should allow for compacted batches' do
217
+ expect(Widget).to receive(:import!).once.and_call_original
218
+
219
+ publish_batch(
220
+ [
221
+ { key: 1,
222
+ payload: nil },
223
+ { key: 2,
224
+ payload: { test_id: 'xyz', some_int: 5 } },
225
+ { key: 1,
226
+ payload: { test_id: 'abc', some_int: 3 } },
227
+ { key: 2,
228
+ payload: { test_id: 'def', some_int: 4 } },
229
+ { key: 3,
230
+ payload: { test_id: 'hij', some_int: 9 } }
231
+ ]
232
+ )
233
+
234
+ expect(all_widgets).
235
+ to match_array(
236
+ [
237
+ have_attributes(id: 1, test_id: 'abc', some_int: 3),
238
+ have_attributes(id: 2, test_id: 'def', some_int: 4),
239
+ have_attributes(id: 3, test_id: 'hij', some_int: 9)
240
+ ]
241
+ )
242
+ end
243
+ end
244
+
245
+ describe 'batch atomicity' do
246
+ it 'should roll back if there was an exception while deleting' do
247
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
248
+
249
+ travel 1.day
250
+
251
+ expect(Widget.connection).to receive(:delete).and_raise('Some error')
252
+
253
+ expect {
254
+ publish_batch(
255
+ [
256
+ { key: 1,
257
+ payload: { test_id: 'def', some_int: 3 } },
258
+ { key: 1,
259
+ payload: nil }
260
+ ]
261
+ )
262
+ }.to raise_error('Some error')
263
+
264
+ expect(all_widgets).
265
+ to match_array(
266
+ [
267
+ have_attributes(id: 1, test_id: 'abc', some_int: 2, updated_at: start, created_at: start)
268
+ ]
269
+ )
270
+ end
271
+
272
+ it 'should roll back if there was an invalid instance while upserting' do
273
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2) # Updated but rolled back
274
+ Widget.create!(id: 3, test_id: 'ghi', some_int: 3) # Removed but rolled back
275
+
276
+ travel 1.day
277
+
278
+ expect {
279
+ publish_batch(
280
+ [
281
+ { key: 1,
282
+ payload: { test_id: 'def', some_int: 3 } },
283
+ { key: 2,
284
+ payload: nil },
285
+ { key: 2,
286
+ payload: { test_id: '', some_int: 4 } }, # Empty string is not valid for test_id
287
+ { key: 3,
288
+ payload: nil }
289
+ ]
290
+ )
291
+ }.to raise_error(ActiveRecord::RecordInvalid)
292
+
293
+ expect(all_widgets).
294
+ to match_array(
295
+ [
296
+ have_attributes(id: 1, test_id: 'abc', some_int: 2, updated_at: start, created_at: start),
297
+ have_attributes(id: 3, test_id: 'ghi', some_int: 3, updated_at: start, created_at: start)
298
+ ]
299
+ )
300
+ end
301
+ end
302
+
303
+ describe 'compound keys' do
304
+ let(:consumer_class) do
305
+ Class.new(described_class) do
306
+ schema 'MySchema'
307
+ namespace 'com.my-namespace'
308
+ key_config schema: 'MySchemaCompound-key'
309
+ record_class Widget
310
+ compacted false
311
+
312
+ # :no-doc:
313
+ def deleted_query(records)
314
+ keys = records.
315
+ map { |m| record_key(m.key) }.
316
+ reject(&:empty?)
317
+
318
+ # Only supported on Rails 5+
319
+ keys.reduce(@klass.none) do |query, key|
320
+ query.or(@klass.unscoped.where(key))
321
+ end
322
+ end
323
+ end
324
+ end
325
+
326
+ it 'should consume with compound keys' do
327
+ Widget.create!(test_id: 'xxx', some_int: 2, part_one: 'ghi', part_two: 'jkl')
328
+ Widget.create!(test_id: 'yyy', some_int: 7, part_one: 'mno', part_two: 'pqr')
329
+
330
+ publish_batch(
331
+ [
332
+ { key: { part_one: 'abc', part_two: 'def' }, # To be created
333
+ payload: { test_id: 'aaa', some_int: 3 } },
334
+ { key: { part_one: 'ghi', part_two: 'jkl' }, # To be updated
335
+ payload: { test_id: 'bbb', some_int: 4 } },
336
+ { key: { part_one: 'mno', part_two: 'pqr' }, # To be deleted
337
+ payload: nil }
338
+ ]
339
+ )
340
+
341
+ expect(all_widgets).
342
+ to match_array(
343
+ [
344
+ have_attributes(test_id: 'aaa', some_int: 3, part_one: 'abc', part_two: 'def'),
345
+ have_attributes(test_id: 'bbb', some_int: 4, part_one: 'ghi', part_two: 'jkl')
346
+ ]
347
+ )
348
+ end
349
+ end
350
+
351
+ describe 'no keys' do
352
+ let(:consumer_class) do
353
+ Class.new(described_class) do
354
+ schema 'MySchema'
355
+ namespace 'com.my-namespace'
356
+ key_config none: true
357
+ record_class Widget
358
+ end
359
+ end
360
+
361
+ it 'should handle unkeyed topics' do
362
+ Widget.create!(test_id: 'xxx', some_int: 2)
363
+
364
+ publish_batch(
365
+ [
366
+ { payload: { test_id: 'aaa', some_int: 3 } },
367
+ { payload: { test_id: 'bbb', some_int: 4 } },
368
+ { payload: nil } # Should be ignored. Can't delete with no key
369
+ ]
370
+ )
371
+
372
+ expect(all_widgets).
373
+ to match_array(
374
+ [
375
+ have_attributes(test_id: 'xxx', some_int: 2),
376
+ have_attributes(test_id: 'aaa', some_int: 3),
377
+ have_attributes(test_id: 'bbb', some_int: 4)
378
+ ]
379
+ )
380
+ end
381
+ end
382
+
383
+ describe 'soft deletion' do
384
+ let(:consumer_class) do
385
+ Class.new(described_class) do
386
+ schema 'MySchema'
387
+ namespace 'com.my-namespace'
388
+ key_config plain: true
389
+ record_class Widget
390
+ compacted false
391
+
392
+ # Sample customization: Soft delete
393
+ def remove_records(records)
394
+ deleted = deleted_query(records)
395
+
396
+ deleted.update_all(
397
+ deleted: true,
398
+ updated_at: Time.zone.now
399
+ )
400
+ end
401
+
402
+ # Sample customization: Undelete records
403
+ def record_attributes(payload, key)
404
+ super.merge(deleted: false)
405
+ end
406
+ end
407
+ end
408
+
409
+ it 'should mark records deleted' do
410
+ Widget.create!(id: 1, test_id: 'abc', some_int: 2)
411
+ Widget.create!(id: 3, test_id: 'xyz', some_int: 4)
412
+ Widget.create!(id: 4, test_id: 'uvw', some_int: 5, deleted: true)
413
+
414
+ travel 1.day
415
+
416
+ publish_batch(
417
+ [
418
+ { key: 1,
419
+ payload: nil },
420
+ { key: 1, # Double delete for key 1
421
+ payload: nil },
422
+ { key: 2, # Create 2
423
+ payload: { test_id: 'def', some_int: 3 } },
424
+ { key: 2, # Delete 2
425
+ payload: nil },
426
+ { key: 3, # Update non-deleted
427
+ payload: { test_id: 'ghi', some_int: 4 } },
428
+ { key: 4, # Revive
429
+ payload: { test_id: 'uvw', some_int: 5 } }
430
+ ]
431
+ )
432
+
433
+ expect(all_widgets).
434
+ to match_array(
435
+ [
436
+ have_attributes(id: 1, test_id: 'abc', some_int: 2, deleted: true,
437
+ created_at: start, updated_at: Time.zone.now),
438
+ have_attributes(id: 2, test_id: 'def', some_int: 3, deleted: true,
439
+ created_at: Time.zone.now, updated_at: Time.zone.now),
440
+ have_attributes(id: 3, test_id: 'ghi', some_int: 4, deleted: false,
441
+ created_at: start, updated_at: Time.zone.now),
442
+ have_attributes(id: 4, test_id: 'uvw', some_int: 5, deleted: false,
443
+ created_at: start, updated_at: Time.zone.now)
444
+ ]
445
+ )
446
+ end
447
+ end
448
+
449
+ describe 'skipping records' do
450
+ let(:consumer_class) do
451
+ Class.new(described_class) do
452
+ schema 'MySchema'
453
+ namespace 'com.my-namespace'
454
+ key_config plain: true
455
+ record_class Widget
456
+
457
+ # Sample customization: Skipping records
458
+ def record_attributes(payload, key)
459
+ return nil if payload[:test_id] == 'skipme'
460
+
461
+ super
462
+ end
463
+ end
464
+ end
465
+
466
+ it 'should allow overriding to skip any unwanted records' do
467
+ publish_batch(
468
+ [
469
+ { key: 1, # Record that consumer can decide to skip
470
+ payload: { test_id: 'skipme' } },
471
+ { key: 2,
472
+ payload: { test_id: 'abc123' } }
473
+ ]
474
+ )
475
+
476
+ expect(all_widgets).
477
+ to match_array([have_attributes(id: 2, test_id: 'abc123')])
478
+ end
479
+ end
480
+ end
481
+ end