db_blaster 0.1.0
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/MIT-LICENSE +20 -0
- data/README.md +46 -0
- data/Rakefile +20 -0
- data/app/assets/config/db_blaster_manifest.js +1 -0
- data/app/assets/stylesheets/db_blaster/application.css +15 -0
- data/app/controllers/db_blaster/application_controller.rb +6 -0
- data/app/helpers/db_blaster/application_helper.rb +7 -0
- data/app/jobs/db_blaster/application_job.rb +7 -0
- data/app/jobs/db_blaster/publish_all_job.rb +20 -0
- data/app/jobs/db_blaster/publish_source_table_job.rb +16 -0
- data/app/models/concerns/db_blaster/sync.rb +21 -0
- data/app/models/db_blaster/application_record.rb +8 -0
- data/app/models/db_blaster/source_table.rb +10 -0
- data/config/brakeman.ignore +26 -0
- data/config/routes.rb +4 -0
- data/db/migrate/20210727222252_create_source_tables.rb +14 -0
- data/lib/db_blaster.rb +28 -0
- data/lib/db_blaster/available_tables.rb +12 -0
- data/lib/db_blaster/chunker.rb +77 -0
- data/lib/db_blaster/configuration.rb +58 -0
- data/lib/db_blaster/engine.rb +12 -0
- data/lib/db_blaster/finder.rb +70 -0
- data/lib/db_blaster/one_record_too_large_error.rb +30 -0
- data/lib/db_blaster/publish_source_table.rb +30 -0
- data/lib/db_blaster/publisher.rb +42 -0
- data/lib/db_blaster/rspec.rb +9 -0
- data/lib/db_blaster/source_table_configuration.rb +19 -0
- data/lib/db_blaster/source_table_configuration_builder.rb +55 -0
- data/lib/db_blaster/version.rb +5 -0
- data/lib/generators/db_blaster/install/install_generator.rb +15 -0
- data/lib/generators/db_blaster/install/templates/db_blaster_config.rb +37 -0
- data/lib/tasks/db_blaster_tasks.rake +5 -0
- metadata +162 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: e8d31a519addfa74e6a2ca3c2d41f58b05e2b4551802948b5a4dc0fe20278dca
|
4
|
+
data.tar.gz: 33a1e76574a2b8e70c681beebf544af587ff143252b08d0dd1a8dbc0b3615864
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 378969c9223acf49c50a055b566b1f3f2bff3a0b032bbc9e1aebbb64b5031c9d1a2af6c539d55efc7e90a8254d0ca4cf413be762ab41dbdd1fd4e01efd723d33
|
7
|
+
data.tar.gz: afabd1c77490c8c80bdd1d0a49965026ae69a77d727ffe7da475c5683391dafc4cd9f91d7880ecc5c0818a4c5afeacd4403844a198f932c0c4d448b0c53cb2dc
|
data/MIT-LICENSE
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
Copyright 2021 Perry Hertler
|
2
|
+
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
4
|
+
a copy of this software and associated documentation files (the
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
9
|
+
the following conditions:
|
10
|
+
|
11
|
+
The above copyright notice and this permission notice shall be
|
12
|
+
included in all copies or substantial portions of the Software.
|
13
|
+
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,46 @@
|
|
1
|
+

|
2
|
+
# DbBlaster
|
3
|
+

|
4
|
+
|
5
|
+
DbBlaster publishes changed database rows to AWS SNS. The first time `DbBlaster::PublishAllJob.perform_later` is ran,
|
6
|
+
the entire database will be incrementally published to SNS. Subsequent runs will publish rows whose `updated_at` column
|
7
|
+
is more recent than the last run.
|
8
|
+
|
9
|
+
Consuming the published messages is functionality not provided by DbBlaster.
|
10
|
+
|
11
|
+
## Usage
|
12
|
+
|
13
|
+
Update `config/initializers/db_blaster_config.rb` with valid AWS credentials, topics, and options.
|
14
|
+
|
15
|
+
Schedule `DbBlaster::PublishAllJob.perform_later` to run periodically with something
|
16
|
+
like [sidekiq-cron](https://github.com/ondrejbartas/sidekiq-cron) or [whenever](https://github.com/javan/whenever)
|
17
|
+
|
18
|
+
## Installation
|
19
|
+
|
20
|
+
Add this line to your application's Gemfile:
|
21
|
+
|
22
|
+
```ruby
|
23
|
+
gem 'db_blaster'
|
24
|
+
```
|
25
|
+
|
26
|
+
And then execute:
|
27
|
+
|
28
|
+
```bash
|
29
|
+
$ bundle
|
30
|
+
```
|
31
|
+
|
32
|
+
Install Migrations:
|
33
|
+
|
34
|
+
```bash
|
35
|
+
$ rake db_blaster:install:migrations && rake db:migrate
|
36
|
+
```
|
37
|
+
|
38
|
+
Copy sample config file to rails project:
|
39
|
+
|
40
|
+
```bash
|
41
|
+
rails g db_blaster:install
|
42
|
+
```
|
43
|
+
|
44
|
+
## License
|
45
|
+
|
46
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'bundler/setup'
|
4
|
+
|
5
|
+
APP_RAKEFILE = File.expand_path('spec/dummy/Rakefile', __dir__)
|
6
|
+
load 'rails/tasks/engine.rake'
|
7
|
+
|
8
|
+
load 'rails/tasks/statistics.rake'
|
9
|
+
|
10
|
+
require 'bundler/gem_tasks'
|
11
|
+
|
12
|
+
begin
|
13
|
+
require 'rspec/core/rake_task'
|
14
|
+
|
15
|
+
RSpec::Core::RakeTask.new(:spec)
|
16
|
+
|
17
|
+
task default: :spec
|
18
|
+
rescue LoadError
|
19
|
+
# no rspec available
|
20
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
//= link_directory ../stylesheets/db_blaster .css
|
@@ -0,0 +1,15 @@
|
|
1
|
+
/*
|
2
|
+
* This is a manifest file that'll be compiled into application.css, which will include all the files
|
3
|
+
* listed below.
|
4
|
+
*
|
5
|
+
* Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets,
|
6
|
+
* or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path.
|
7
|
+
*
|
8
|
+
* You're free to add application-wide styles to this file and they'll appear at the bottom of the
|
9
|
+
* compiled file so the styles you add here take precedence over styles defined in any other CSS/SCSS
|
10
|
+
* files in this directory. Styles in this file should be added after the last require_* statement.
|
11
|
+
* It is generally better to create a new file per style scope.
|
12
|
+
*
|
13
|
+
*= require_tree .
|
14
|
+
*= require_self
|
15
|
+
*/
|
@@ -0,0 +1,20 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# ActiveJob
|
4
|
+
# 1) sync configuration with DbBlaster::SourceTable
|
5
|
+
# 2) Enqueue PublishSourceTableJob for every source_table
|
6
|
+
module DbBlaster
|
7
|
+
# Enqueues PublishSourceTableJob for every source-table
|
8
|
+
class PublishAllJob < ApplicationJob
|
9
|
+
queue_as 'default'
|
10
|
+
|
11
|
+
def perform
|
12
|
+
SourceTable.sync(SourceTableConfigurationBuilder
|
13
|
+
.build_all(DbBlaster.configuration))
|
14
|
+
|
15
|
+
DbBlaster::SourceTable.pluck(:id).each do |source_table_id|
|
16
|
+
PublishSourceTableJob.perform_later(source_table_id)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,16 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Executes PublishSourceTable for provided `source_table_id`
|
4
|
+
module DbBlaster
|
5
|
+
# Publishes changed rows to SNS
|
6
|
+
class PublishSourceTableJob < ApplicationJob
|
7
|
+
queue_as 'default'
|
8
|
+
|
9
|
+
def perform(source_table_id)
|
10
|
+
source_table = SourceTable.find_by(id: source_table_id)
|
11
|
+
return unless source_table
|
12
|
+
|
13
|
+
PublishSourceTable.execute(source_table)
|
14
|
+
end
|
15
|
+
end
|
16
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Syncs tables derived from SourceTableConfigurationBuilder
|
5
|
+
# with SourceTable rows
|
6
|
+
module Sync
|
7
|
+
extend ActiveSupport::Concern
|
8
|
+
|
9
|
+
class_methods do
|
10
|
+
# Syncs configuration tables with db
|
11
|
+
def sync(table_configs)
|
12
|
+
SourceTable.where.not(name: table_configs.collect(&:source_table_name)).delete_all
|
13
|
+
|
14
|
+
table_configs.each do |config|
|
15
|
+
source_table = SourceTable.where(name: config.source_table_name).first_or_create
|
16
|
+
source_table.update!(config.update_params)
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
@@ -0,0 +1,26 @@
|
|
1
|
+
{
|
2
|
+
"ignored_warnings": [
|
3
|
+
{
|
4
|
+
"warning_type": "SQL Injection",
|
5
|
+
"warning_code": 0,
|
6
|
+
"fingerprint": "6f4d3da0707c3f5f5c5bf5a002a254fee246210248aafa655cb2f15adfb47aa7",
|
7
|
+
"check_name": "SQL",
|
8
|
+
"message": "Possible SQL injection",
|
9
|
+
"file": "lib/db_blaster/finder.rb",
|
10
|
+
"line": 38,
|
11
|
+
"link": "https://brakemanscanner.org/docs/warning_types/sql_injection/",
|
12
|
+
"code": "ActiveRecord::Base.connection.execute(\"#{select_sql} OFFSET #{offset}\")",
|
13
|
+
"render_path": null,
|
14
|
+
"location": {
|
15
|
+
"type": "method",
|
16
|
+
"class": "DbBlaster::Finder",
|
17
|
+
"method": "find_records_in_batches"
|
18
|
+
},
|
19
|
+
"user_input": "select_sql",
|
20
|
+
"confidence": "Medium",
|
21
|
+
"note": "No SQL injection can occur"
|
22
|
+
}
|
23
|
+
],
|
24
|
+
"updated": "2021-08-09 11:03:06 -0600",
|
25
|
+
"brakeman_version": "5.1.1"
|
26
|
+
}
|
data/config/routes.rb
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# Create db_blaster_source_tables
|
4
|
+
class CreateSourceTables < ActiveRecord::Migration[6.1]
|
5
|
+
def change
|
6
|
+
create_table :db_blaster_source_tables do |t|
|
7
|
+
t.string :name, null: false
|
8
|
+
t.text :ignored_columns, array: true, default: []
|
9
|
+
t.datetime :last_published_updated_at # the most recent published `updated_at`
|
10
|
+
t.integer :batch_size, default: 100
|
11
|
+
t.timestamps
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
data/lib/db_blaster.rb
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'aws-sdk-sns'
|
4
|
+
require 'db_blaster/version'
|
5
|
+
require 'db_blaster/engine'
|
6
|
+
require 'db_blaster/one_record_too_large_error'
|
7
|
+
require 'db_blaster/available_tables'
|
8
|
+
require 'db_blaster/configuration'
|
9
|
+
require 'db_blaster/source_table_configuration'
|
10
|
+
require 'db_blaster/source_table_configuration_builder'
|
11
|
+
require 'db_blaster/publisher'
|
12
|
+
require 'db_blaster/publish_source_table'
|
13
|
+
require 'db_blaster/chunker'
|
14
|
+
require 'db_blaster/finder'
|
15
|
+
|
16
|
+
# Top-level module that serves as an entry point
|
17
|
+
# into the engine gem
|
18
|
+
module DbBlaster
|
19
|
+
class << self
|
20
|
+
def configuration
|
21
|
+
@configuration ||= Configuration.new
|
22
|
+
end
|
23
|
+
|
24
|
+
def configure
|
25
|
+
yield(configuration)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,12 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Finds all tables in current database and removes the "system tables"
|
5
|
+
module AvailableTables
|
6
|
+
SYSTEM_TABLES = %w[schema_migrations ar_internal_metadata].freeze
|
7
|
+
|
8
|
+
def available_tables
|
9
|
+
@available_tables ||= ActiveRecord::Base.connection.tables - SYSTEM_TABLES
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Chunk the records into sizes < Configuration.max_message_size_in_kilobytes
|
5
|
+
# yielding the chunks inline to the provided block
|
6
|
+
# If the records' size is already less than Configuration.max_message_size_in_kilobytes,
|
7
|
+
# all the records are yielded to the provided block
|
8
|
+
class Chunker
|
9
|
+
attr_reader :source_table, :records, :block_on_chunk, :current_chunk, :current_chunk_size
|
10
|
+
|
11
|
+
def initialize(source_table, records, &block_on_chunk)
|
12
|
+
@source_table = source_table
|
13
|
+
@records = records
|
14
|
+
@block_on_chunk = block_on_chunk
|
15
|
+
@current_chunk_size = 0
|
16
|
+
@current_chunk = []
|
17
|
+
end
|
18
|
+
|
19
|
+
def self.chunk(source_table, records, &block)
|
20
|
+
new(source_table, records, &block).chunk
|
21
|
+
end
|
22
|
+
|
23
|
+
def chunk
|
24
|
+
return if yield_if_records_acceptable?
|
25
|
+
|
26
|
+
breakup_records
|
27
|
+
end
|
28
|
+
|
29
|
+
def yield_if_records_acceptable?
|
30
|
+
return if records.to_json.size >= max_bytes
|
31
|
+
|
32
|
+
block_on_chunk.call(records)
|
33
|
+
true
|
34
|
+
end
|
35
|
+
|
36
|
+
def max_bytes
|
37
|
+
@max_bytes ||= 1000 * max_kilobytes
|
38
|
+
end
|
39
|
+
|
40
|
+
def max_kilobytes
|
41
|
+
DbBlaster.configuration.max_message_size_in_kilobytes ||
|
42
|
+
DbBlaster.configuration.class::DEFAULT_MAX_MESSAGE_SIZE_IN_KILOBYTES
|
43
|
+
end
|
44
|
+
|
45
|
+
def breakup_records
|
46
|
+
records.each(&method(:process_record))
|
47
|
+
block_on_chunk.call(current_chunk) if current_chunk.length.positive?
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
def process_record(record)
|
53
|
+
record_size = record.to_json.size
|
54
|
+
@current_chunk_size += record_size
|
55
|
+
if current_chunk_size < max_bytes
|
56
|
+
current_chunk << record
|
57
|
+
elsif record_size >= max_bytes
|
58
|
+
blow_up_one_record_too_large(record)
|
59
|
+
else
|
60
|
+
yield_chunk(record, record_size)
|
61
|
+
end
|
62
|
+
end
|
63
|
+
|
64
|
+
def yield_chunk(record, record_size)
|
65
|
+
block_on_chunk.call(current_chunk)
|
66
|
+
@current_chunk_size = record_size
|
67
|
+
@current_chunk = [record]
|
68
|
+
end
|
69
|
+
|
70
|
+
def blow_up_one_record_too_large(record)
|
71
|
+
block_on_chunk.call(current_chunk) if current_chunk.length.positive?
|
72
|
+
raise OneRecordTooLargeError.new(source_table: source_table,
|
73
|
+
record: record,
|
74
|
+
max_kilobytes: max_kilobytes)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,58 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Configuration class for providing credentials, topics, and customizations.
|
5
|
+
class Configuration
|
6
|
+
DEFAULT_BATCH_SIZE = 100
|
7
|
+
DEFAULT_MAX_MESSAGE_SIZE_IN_KILOBYTES = 256 # max size allowed by AWS SNS
|
8
|
+
|
9
|
+
# The required configuration fields
|
10
|
+
REQUIRED_FIELDS = %i[aws_access_key aws_access_secret aws_region sns_topic].freeze
|
11
|
+
|
12
|
+
# The topic to which messages will be published
|
13
|
+
attr_accessor :sns_topic
|
14
|
+
attr_accessor :aws_access_key, :aws_access_secret, :aws_region
|
15
|
+
|
16
|
+
# Global list of column names not to include in published SNS messages
|
17
|
+
# example: config.ignored_column_names = ['email', 'phone_number']
|
18
|
+
attr_accessor :ignored_column_names
|
19
|
+
|
20
|
+
# Optional
|
21
|
+
# If set, only publish tables specified.
|
22
|
+
# example: config.only_source_tables = ['posts', 'tags', 'comments']
|
23
|
+
attr_accessor :only_source_tables
|
24
|
+
|
25
|
+
# Optional
|
26
|
+
# Customize batch_size and/or ignored_columns
|
27
|
+
# example:
|
28
|
+
# config.source_table_options = [{ source_table_name: 'posts', batch_size: 100, ignored_column_names: ['email'] },
|
29
|
+
# { source_table_name: 'comments', ignored_column_names: ['tags'] }]
|
30
|
+
attr_accessor :source_table_options
|
31
|
+
|
32
|
+
# Optional
|
33
|
+
# Extra [SNS message_attributes](https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html)
|
34
|
+
# Attributes set here will be included in every published message
|
35
|
+
# example: config.extra_sns_message_attributes = {'infra_id' => {data_type: 'String', value: '061'}}
|
36
|
+
attr_accessor :extra_sns_message_attributes
|
37
|
+
|
38
|
+
# Optional
|
39
|
+
# db_blaster will select and then publish `batch_size` rows at a time
|
40
|
+
# Default value is 100
|
41
|
+
attr_accessor :batch_size
|
42
|
+
|
43
|
+
# Optional
|
44
|
+
# DbBlaster will publish no messages larger than this value
|
45
|
+
# Default value is 256
|
46
|
+
attr_accessor :max_message_size_in_kilobytes
|
47
|
+
|
48
|
+
# Raises error if a required field is not set
|
49
|
+
def verify!
|
50
|
+
no_values = REQUIRED_FIELDS.select do |attribute|
|
51
|
+
send(attribute).nil? || send(attribute).strip.empty?
|
52
|
+
end
|
53
|
+
return if no_values.empty?
|
54
|
+
|
55
|
+
raise "missing configuration values for [#{no_values.join(', ')}]"
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Find records and yield them a `batch_size` at a time
|
5
|
+
class Finder
|
6
|
+
include AvailableTables
|
7
|
+
attr_reader :source_table, :block_on_find, :offset
|
8
|
+
|
9
|
+
def initialize(source_table, &block)
|
10
|
+
@source_table = source_table
|
11
|
+
@block_on_find = block
|
12
|
+
@offset = 0
|
13
|
+
end
|
14
|
+
|
15
|
+
delegate :batch_size, :name, :last_published_updated_at, to: :source_table, prefix: true
|
16
|
+
|
17
|
+
def self.find(source_table, &block)
|
18
|
+
new(source_table, &block).find
|
19
|
+
end
|
20
|
+
|
21
|
+
def find
|
22
|
+
verify_source_table_name
|
23
|
+
|
24
|
+
find_records_in_batches do |batch|
|
25
|
+
filtered = batch.collect(&method(:filter_columns))
|
26
|
+
next if filtered.blank?
|
27
|
+
|
28
|
+
Chunker.chunk(source_table, filtered) do |chunked|
|
29
|
+
block_on_find.call(chunked)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def find_records_in_batches
|
37
|
+
loop do
|
38
|
+
result = ActiveRecord::Base.connection.execute("#{select_sql} OFFSET #{offset}")
|
39
|
+
yield(result)
|
40
|
+
break if result.count != source_table_batch_size
|
41
|
+
|
42
|
+
@offset += source_table_batch_size
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
def filter_columns(selected_row)
|
47
|
+
selected_row.except(*source_table.ignored_columns)
|
48
|
+
end
|
49
|
+
|
50
|
+
def verify_source_table_name
|
51
|
+
raise invalid_source_table_message unless available_tables.include?(source_table_name)
|
52
|
+
end
|
53
|
+
|
54
|
+
def invalid_source_table_message
|
55
|
+
"source_table.name: '#{source_table_name}' does not exist!"
|
56
|
+
end
|
57
|
+
|
58
|
+
def select_sql
|
59
|
+
"SELECT * FROM #{source_table_name} #{where} ORDER BY updated_at ASC LIMIT #{source_table_batch_size}"
|
60
|
+
end
|
61
|
+
|
62
|
+
def where
|
63
|
+
return '' unless source_table_last_published_updated_at
|
64
|
+
|
65
|
+
ActiveRecord::Base.sanitize_sql_for_conditions(
|
66
|
+
['WHERE updated_at >= :updated_at', { updated_at: source_table_last_published_updated_at.to_s(:db) }]
|
67
|
+
)
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Custom error representing a serious error.
|
5
|
+
# If one individual record is larger than the max bytes
|
6
|
+
# allowed, db_blaster "fails fast".
|
7
|
+
# If we ignored this record and moved on, it would never make it
|
8
|
+
# to SNS as its `updated_at` would end up being less than the
|
9
|
+
# last processed record.
|
10
|
+
# Possible fixes:
|
11
|
+
# 1) if possible ignore the column(s) that are too large
|
12
|
+
# 2) manually move the record to the intended destination
|
13
|
+
# 3) bug me to provide an S3 workaround
|
14
|
+
class OneRecordTooLargeError < StandardError
|
15
|
+
attr_reader :source_table, :record, :max_kilobytes
|
16
|
+
|
17
|
+
def initialize(params)
|
18
|
+
super
|
19
|
+
params.each_key do |key|
|
20
|
+
instance_variable_set("@#{key}", params[key])
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
def message
|
25
|
+
["One individual record with ID '#{record[:id]}'",
|
26
|
+
" in source-table '#{source_table.name}'",
|
27
|
+
" is larger than #{max_kilobytes} KB!"].join(' ')
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,30 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Given a `source_table` providing the table name,
|
5
|
+
# finds rows in `batch_size` chunks that are published to SNS
|
6
|
+
class PublishSourceTable
|
7
|
+
attr_reader :source_table
|
8
|
+
|
9
|
+
def initialize(source_table)
|
10
|
+
@source_table = source_table
|
11
|
+
end
|
12
|
+
|
13
|
+
def self.execute(source_table)
|
14
|
+
new(source_table).execute
|
15
|
+
end
|
16
|
+
|
17
|
+
def execute
|
18
|
+
DbBlaster.configuration.verify! # will raise error if required configurations are not set
|
19
|
+
|
20
|
+
# pessimistically lock row for the duration
|
21
|
+
source_table.with_lock do
|
22
|
+
Finder.find(source_table) do |records|
|
23
|
+
Publisher.publish(source_table, records)
|
24
|
+
source_table.update(last_published_updated_at: records.last['updated_at'])
|
25
|
+
end
|
26
|
+
end
|
27
|
+
self
|
28
|
+
end
|
29
|
+
end
|
30
|
+
end
|
@@ -0,0 +1,42 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
# publish records to SNS topic
|
4
|
+
module DbBlaster
|
5
|
+
# Publishes records to AWS SNS
|
6
|
+
class Publisher
|
7
|
+
attr_reader :source_table, :records
|
8
|
+
|
9
|
+
def initialize(source_table, records)
|
10
|
+
@source_table = source_table
|
11
|
+
@records = records
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.publish(source_table, records)
|
15
|
+
new(source_table, records).publish
|
16
|
+
end
|
17
|
+
|
18
|
+
def publish
|
19
|
+
topic.publish(message_attributes: message_attributes,
|
20
|
+
message: records.to_json)
|
21
|
+
end
|
22
|
+
|
23
|
+
def topic
|
24
|
+
resource.topic(DbBlaster.configuration.sns_topic)
|
25
|
+
end
|
26
|
+
|
27
|
+
def resource
|
28
|
+
@resource ||= Aws::SNS::Resource.new(client: client)
|
29
|
+
end
|
30
|
+
|
31
|
+
def client
|
32
|
+
@client ||= Aws::SNS::Client.new(region: DbBlaster.configuration.aws_region,
|
33
|
+
credentials: Aws::Credentials.new(DbBlaster.configuration.aws_access_key,
|
34
|
+
DbBlaster.configuration.aws_access_secret))
|
35
|
+
end
|
36
|
+
|
37
|
+
def message_attributes
|
38
|
+
(DbBlaster.configuration.extra_sns_message_attributes || {})
|
39
|
+
.merge('source_table' => { data_type: 'String', string_value: source_table.name })
|
40
|
+
end
|
41
|
+
end
|
42
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# PORO for source-table-configuration fields
|
5
|
+
class SourceTableConfiguration
|
6
|
+
attr_reader :source_table_name, :batch_size, :ignored_column_names
|
7
|
+
|
8
|
+
def initialize(params)
|
9
|
+
params.each_key do |key|
|
10
|
+
instance_variable_set("@#{key}", params[key])
|
11
|
+
end
|
12
|
+
end
|
13
|
+
|
14
|
+
def update_params
|
15
|
+
{ batch_size: batch_size,
|
16
|
+
ignored_columns: ignored_column_names }
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
# Builds source-table configurations based off the tables in the
|
5
|
+
# current database and the provided DbBlaster::Configuration
|
6
|
+
class SourceTableConfigurationBuilder
|
7
|
+
include AvailableTables
|
8
|
+
attr_reader :configuration, :source_table_configurations
|
9
|
+
|
10
|
+
def initialize(configuration)
|
11
|
+
@configuration = configuration
|
12
|
+
@source_table_configurations = []
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.build_all(configuration)
|
16
|
+
new(configuration).build_all
|
17
|
+
end
|
18
|
+
|
19
|
+
def build_all
|
20
|
+
@build_all ||= table_names_for_configuration
|
21
|
+
.collect(&method(:build_configuration))
|
22
|
+
end
|
23
|
+
|
24
|
+
def build_configuration(source_table_name)
|
25
|
+
SourceTableConfiguration.new(source_table_name: source_table_name,
|
26
|
+
batch_size: batch_size(source_table_name),
|
27
|
+
ignored_column_names: ignored_column_names(source_table_name))
|
28
|
+
end
|
29
|
+
|
30
|
+
def batch_size(source_table_name)
|
31
|
+
overridden_value_or_global(source_table_name, :batch_size) || configuration.class::DEFAULT_BATCH_SIZE
|
32
|
+
end
|
33
|
+
|
34
|
+
def ignored_column_names(source_table_name)
|
35
|
+
overridden_value_or_global(source_table_name, :ignored_column_names) || []
|
36
|
+
end
|
37
|
+
|
38
|
+
def overridden_value_or_global(source_table_name, field_name)
|
39
|
+
find_source_table_options(source_table_name)&.send(:[], field_name) || configuration.send(field_name)
|
40
|
+
end
|
41
|
+
|
42
|
+
def find_source_table_options(source_table_name)
|
43
|
+
(configuration.source_table_options || [])
|
44
|
+
.detect { |option| option[:source_table_name] == source_table_name }
|
45
|
+
end
|
46
|
+
|
47
|
+
def table_names_for_configuration
|
48
|
+
if configuration.only_source_tables&.length&.positive?
|
49
|
+
available_tables & configuration.only_source_tables
|
50
|
+
else
|
51
|
+
available_tables
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
@@ -0,0 +1,15 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DbBlaster
|
4
|
+
module Generators
|
5
|
+
# Generator to copy a sample db_blaster_config.rb to the
|
6
|
+
# rails app's config/initializer directory
|
7
|
+
class InstallGenerator < ::Rails::Generators::Base
|
8
|
+
source_root File.expand_path('templates', __dir__)
|
9
|
+
|
10
|
+
def copy_initializer_file
|
11
|
+
copy_file 'db_blaster_config.rb', 'config/initializers/db_blaster_config.rb'
|
12
|
+
end
|
13
|
+
end
|
14
|
+
end
|
15
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
DbBlaster.configure do |config|
|
4
|
+
# SNS topic to receive database changes
|
5
|
+
config.sns_topic = 'the-topic'
|
6
|
+
config.aws_access_key = 'access-key'
|
7
|
+
config.aws_access_secret = 'secret'
|
8
|
+
config.aws_region = 'region'
|
9
|
+
|
10
|
+
# Optional
|
11
|
+
# db_blaster will select and then publish `batch_size` rows at a time
|
12
|
+
# config.batch_size = 100
|
13
|
+
|
14
|
+
# Optional
|
15
|
+
# db_blaster will publish no messages larger than this value
|
16
|
+
# Default value is 256
|
17
|
+
# attr_accessor :max_message_size_in_kilobytes
|
18
|
+
|
19
|
+
# Optional
|
20
|
+
# Extra [SNS message_attributes](https://docs.aws.amazon.com/sns/latest/dg/sns-message-attributes.html)
|
21
|
+
# Attributes set here will be included in every published message
|
22
|
+
# config.extra_sns_message_attributes = {'infra_id' => {data_type: 'String', value: '061'}}
|
23
|
+
|
24
|
+
# Global list of column names not to include in published SNS messages
|
25
|
+
# example: config.ignored_column_names = ['email', 'phone_number']
|
26
|
+
# config.ignored_column_names = ['email', 'phone_number']
|
27
|
+
|
28
|
+
# Optional
|
29
|
+
# If set, only publish tables specified.
|
30
|
+
# config.only_source_tables = ['posts', 'tags', 'comments']
|
31
|
+
|
32
|
+
# Optional
|
33
|
+
# Customize batch_size and/or ignored_columns
|
34
|
+
# example:
|
35
|
+
# config.source_table_options = [{ source_table_name: 'posts', batch_size: 100, ignored_column_names: ['email'] },
|
36
|
+
# { source_table_name: 'comments', ignored_column_names: ['tags'] }]
|
37
|
+
end
|
metadata
ADDED
@@ -0,0 +1,162 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: db_blaster
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: 0.1.0
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Perry Hertler
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2021-08-09 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: aws-sdk-sns
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - ">="
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '0'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - ">="
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '0'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: rails
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - ">="
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - ">="
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: factory_bot_rails
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - ">="
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - ">="
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
- !ruby/object:Gem::Dependency
|
56
|
+
name: rspec-its
|
57
|
+
requirement: !ruby/object:Gem::Requirement
|
58
|
+
requirements:
|
59
|
+
- - ">="
|
60
|
+
- !ruby/object:Gem::Version
|
61
|
+
version: '0'
|
62
|
+
type: :development
|
63
|
+
prerelease: false
|
64
|
+
version_requirements: !ruby/object:Gem::Requirement
|
65
|
+
requirements:
|
66
|
+
- - ">="
|
67
|
+
- !ruby/object:Gem::Version
|
68
|
+
version: '0'
|
69
|
+
- !ruby/object:Gem::Dependency
|
70
|
+
name: rspec-rails
|
71
|
+
requirement: !ruby/object:Gem::Requirement
|
72
|
+
requirements:
|
73
|
+
- - ">="
|
74
|
+
- !ruby/object:Gem::Version
|
75
|
+
version: '0'
|
76
|
+
type: :development
|
77
|
+
prerelease: false
|
78
|
+
version_requirements: !ruby/object:Gem::Requirement
|
79
|
+
requirements:
|
80
|
+
- - ">="
|
81
|
+
- !ruby/object:Gem::Version
|
82
|
+
version: '0'
|
83
|
+
- !ruby/object:Gem::Dependency
|
84
|
+
name: shoulda-matchers
|
85
|
+
requirement: !ruby/object:Gem::Requirement
|
86
|
+
requirements:
|
87
|
+
- - ">="
|
88
|
+
- !ruby/object:Gem::Version
|
89
|
+
version: '0'
|
90
|
+
type: :development
|
91
|
+
prerelease: false
|
92
|
+
version_requirements: !ruby/object:Gem::Requirement
|
93
|
+
requirements:
|
94
|
+
- - ">="
|
95
|
+
- !ruby/object:Gem::Version
|
96
|
+
version: '0'
|
97
|
+
description: Push db changes to AWS SNS.
|
98
|
+
email:
|
99
|
+
- perry@hertler.org
|
100
|
+
executables: []
|
101
|
+
extensions: []
|
102
|
+
extra_rdoc_files: []
|
103
|
+
files:
|
104
|
+
- MIT-LICENSE
|
105
|
+
- README.md
|
106
|
+
- Rakefile
|
107
|
+
- app/assets/config/db_blaster_manifest.js
|
108
|
+
- app/assets/stylesheets/db_blaster/application.css
|
109
|
+
- app/controllers/db_blaster/application_controller.rb
|
110
|
+
- app/helpers/db_blaster/application_helper.rb
|
111
|
+
- app/jobs/db_blaster/application_job.rb
|
112
|
+
- app/jobs/db_blaster/publish_all_job.rb
|
113
|
+
- app/jobs/db_blaster/publish_source_table_job.rb
|
114
|
+
- app/models/concerns/db_blaster/sync.rb
|
115
|
+
- app/models/db_blaster/application_record.rb
|
116
|
+
- app/models/db_blaster/source_table.rb
|
117
|
+
- config/brakeman.ignore
|
118
|
+
- config/routes.rb
|
119
|
+
- db/migrate/20210727222252_create_source_tables.rb
|
120
|
+
- lib/db_blaster.rb
|
121
|
+
- lib/db_blaster/available_tables.rb
|
122
|
+
- lib/db_blaster/chunker.rb
|
123
|
+
- lib/db_blaster/configuration.rb
|
124
|
+
- lib/db_blaster/engine.rb
|
125
|
+
- lib/db_blaster/finder.rb
|
126
|
+
- lib/db_blaster/one_record_too_large_error.rb
|
127
|
+
- lib/db_blaster/publish_source_table.rb
|
128
|
+
- lib/db_blaster/publisher.rb
|
129
|
+
- lib/db_blaster/rspec.rb
|
130
|
+
- lib/db_blaster/source_table_configuration.rb
|
131
|
+
- lib/db_blaster/source_table_configuration_builder.rb
|
132
|
+
- lib/db_blaster/version.rb
|
133
|
+
- lib/generators/db_blaster/install/install_generator.rb
|
134
|
+
- lib/generators/db_blaster/install/templates/db_blaster_config.rb
|
135
|
+
- lib/tasks/db_blaster_tasks.rake
|
136
|
+
homepage: https://github.com/perryqh/db_blaster
|
137
|
+
licenses:
|
138
|
+
- MIT
|
139
|
+
metadata:
|
140
|
+
homepage_uri: https://github.com/perryqh/db_blaster
|
141
|
+
source_code_uri: https://github.com/perryqh/db_blaster
|
142
|
+
changelog_uri: https://github.com/perryqh/db_blaster/CHANGELOG.md
|
143
|
+
post_install_message:
|
144
|
+
rdoc_options: []
|
145
|
+
require_paths:
|
146
|
+
- lib
|
147
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
148
|
+
requirements:
|
149
|
+
- - ">="
|
150
|
+
- !ruby/object:Gem::Version
|
151
|
+
version: '2.6'
|
152
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
153
|
+
requirements:
|
154
|
+
- - ">="
|
155
|
+
- !ruby/object:Gem::Version
|
156
|
+
version: '0'
|
157
|
+
requirements: []
|
158
|
+
rubygems_version: 3.1.6
|
159
|
+
signing_key:
|
160
|
+
specification_version: 4
|
161
|
+
summary: Push db changes to AWS SNS.
|
162
|
+
test_files: []
|