deimos-ruby 1.7.0.pre.beta1 → 1.8.1.pre.beta3

Sign up to get free protection for your applications and to get access to all the features.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.rubocop.yml +8 -4
  3. data/CHANGELOG.md +50 -0
  4. data/Gemfile.lock +109 -75
  5. data/README.md +147 -16
  6. data/deimos-ruby.gemspec +4 -2
  7. data/docs/ARCHITECTURE.md +144 -0
  8. data/docs/CONFIGURATION.md +4 -0
  9. data/lib/deimos.rb +8 -7
  10. data/lib/deimos/active_record_consume/batch_consumption.rb +159 -0
  11. data/lib/deimos/active_record_consume/batch_slicer.rb +27 -0
  12. data/lib/deimos/active_record_consume/message_consumption.rb +58 -0
  13. data/lib/deimos/active_record_consume/schema_model_converter.rb +52 -0
  14. data/lib/deimos/active_record_consumer.rb +33 -75
  15. data/lib/deimos/batch_consumer.rb +2 -142
  16. data/lib/deimos/config/configuration.rb +8 -10
  17. data/lib/deimos/consume/batch_consumption.rb +150 -0
  18. data/lib/deimos/consume/message_consumption.rb +94 -0
  19. data/lib/deimos/consumer.rb +79 -72
  20. data/lib/deimos/instrumentation.rb +10 -5
  21. data/lib/deimos/kafka_message.rb +1 -1
  22. data/lib/deimos/kafka_topic_info.rb +21 -2
  23. data/lib/deimos/message.rb +6 -1
  24. data/lib/deimos/schema_backends/avro_base.rb +33 -1
  25. data/lib/deimos/schema_backends/avro_schema_coercer.rb +30 -11
  26. data/lib/deimos/schema_backends/base.rb +21 -2
  27. data/lib/deimos/utils/db_poller.rb +6 -6
  28. data/lib/deimos/utils/db_producer.rb +57 -15
  29. data/lib/deimos/utils/deadlock_retry.rb +68 -0
  30. data/lib/deimos/utils/lag_reporter.rb +19 -26
  31. data/lib/deimos/utils/schema_controller_mixin.rb +111 -0
  32. data/lib/deimos/version.rb +1 -1
  33. data/lib/generators/deimos/active_record/templates/migration.rb.tt +28 -0
  34. data/lib/generators/deimos/active_record/templates/model.rb.tt +5 -0
  35. data/lib/generators/deimos/active_record_generator.rb +79 -0
  36. data/lib/generators/deimos/db_backend/templates/migration +1 -0
  37. data/lib/generators/deimos/db_backend/templates/rails3_migration +1 -0
  38. data/spec/active_record_batch_consumer_spec.rb +481 -0
  39. data/spec/active_record_consume/batch_slicer_spec.rb +42 -0
  40. data/spec/active_record_consume/schema_model_converter_spec.rb +105 -0
  41. data/spec/active_record_consumer_spec.rb +3 -11
  42. data/spec/batch_consumer_spec.rb +24 -7
  43. data/spec/config/configuration_spec.rb +4 -0
  44. data/spec/consumer_spec.rb +6 -6
  45. data/spec/deimos_spec.rb +57 -49
  46. data/spec/generators/active_record_generator_spec.rb +56 -0
  47. data/spec/handlers/my_batch_consumer.rb +6 -1
  48. data/spec/handlers/my_consumer.rb +6 -1
  49. data/spec/kafka_listener_spec.rb +54 -0
  50. data/spec/kafka_topic_info_spec.rb +39 -16
  51. data/spec/message_spec.rb +19 -0
  52. data/spec/producer_spec.rb +34 -0
  53. data/spec/schemas/com/my-namespace/Generated.avsc +71 -0
  54. data/spec/schemas/com/my-namespace/MyNestedSchema.avsc +55 -0
  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/schemas/com/my-namespace/request/Index.avsc +11 -0
  58. data/spec/schemas/com/my-namespace/request/UpdateRequest.avsc +11 -0
  59. data/spec/schemas/com/my-namespace/response/Index.avsc +11 -0
  60. data/spec/schemas/com/my-namespace/response/UpdateResponse.avsc +11 -0
  61. data/spec/spec_helper.rb +24 -0
  62. data/spec/utils/db_poller_spec.rb +2 -2
  63. data/spec/utils/db_producer_spec.rb +84 -10
  64. data/spec/utils/deadlock_retry_spec.rb +74 -0
  65. data/spec/utils/lag_reporter_spec.rb +29 -22
  66. data/spec/utils/schema_controller_mixin_spec.rb +68 -0
  67. metadata +87 -30
  68. data/lib/deimos/base_consumer.rb +0 -100
  69. data/lib/deimos/utils/executor.rb +0 -124
  70. data/lib/deimos/utils/platform_schema_validation.rb +0 -0
  71. data/lib/deimos/utils/signal_handler.rb +0 -68
  72. data/spec/utils/executor_spec.rb +0 -53
  73. 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.7.0-beta1'
4
+ VERSION = '1.8.1-beta3'
5
5
  end
@@ -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,5 @@
1
+ class <%= table_name.classify %> < ApplicationRecord
2
+ <%- fields.select { |f| f.enum_values.any? }.each do |field| -%>
3
+ enum <%= field.name %>: {<%= field.enum_values.map { |v| "#{v}: '#{v}'"}.join(', ') %>}
4
+ <% end -%>
5
+ 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]
@@ -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