pgdice 0.1.0 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # ConfigurationFileLoader is a class used to load the PgDice configuration file
5
+ class ConfigurationFileLoader
6
+ include PgDice::LogHelper
7
+ extend Forwardable
8
+
9
+ attr_reader :config
10
+
11
+ def_delegators :@config, :config_file, :logger
12
+
13
+ def initialize(config = PgDice::Configuration.new, opts = {})
14
+ @config = config
15
+ @file_validator = opts[:file_validator] ||= lambda do |config_file|
16
+ validate_file(config_file)
17
+ end
18
+ @config_loader = opts[:config_loader] ||= lambda do |file|
19
+ logger.debug { "Loading PgDice configuration file: '#{config_file}'" }
20
+ YAML.safe_load(ERB.new(IO.read(file)).result)
21
+ end
22
+ @file_loaded = opts[:file_loaded]
23
+ end
24
+
25
+ def load_file
26
+ return if @file_loaded
27
+
28
+ @file_loaded = true
29
+
30
+ @file_validator.call(config_file)
31
+
32
+ @config.approved_tables = @config_loader.call(config_file)
33
+ .fetch('approved_tables')
34
+ .reduce(tables(@config)) do |tables, hash|
35
+ tables << PgDice::Table.from_hash(hash)
36
+ end
37
+ end
38
+
39
+ def file_loaded?
40
+ @file_loaded
41
+ end
42
+
43
+ private
44
+
45
+ def validate_file(config_file)
46
+ if blank?(config_file)
47
+ raise PgDice::InvalidConfigurationError,
48
+ 'Cannot read in nil configuration file! You must set config_file if you leave approved_tables nil!'
49
+ end
50
+
51
+ raise PgDice::MissingConfigurationFileError, config_file unless File.exist?(config_file)
52
+ end
53
+
54
+ def tables(config)
55
+ if config.approved_tables(eager_load: true).is_a?(PgDice::ApprovedTables)
56
+ return config.approved_tables(eager_load: true)
57
+ end
58
+
59
+ PgDice::ApprovedTables.new
60
+ end
61
+ end
62
+ end
@@ -4,25 +4,33 @@
4
4
  module PgDice
5
5
  # Wrapper class around database connection handlers
6
6
  class DatabaseConnection
7
- extend Forwardable
8
- def_delegators :@configuration, :logger, :dry_run, :pg_connection
7
+ include PgDice::LogHelper
9
8
 
10
- def initialize(configuration = PgDice::Configuration.new)
11
- @configuration = configuration
9
+ attr_reader :logger, :query_executor, :dry_run
10
+
11
+ def initialize(logger:, query_executor:, dry_run: false)
12
+ @logger = logger
13
+ @dry_run = dry_run
14
+ @query_executor = query_executor
12
15
  end
13
16
 
14
17
  def execute(query)
18
+ query = squish(query)
19
+
20
+ if blank?(query) || dry_run
21
+ logger.debug { "DatabaseConnection skipping query. Query: '#{query}'. Dry run: #{dry_run}" }
22
+ return PgDice::PgResponse.new
23
+ end
24
+
15
25
  logger.debug { "DatabaseConnection to execute query: #{query}" }
16
- if dry_run
17
- PgDicePgResponse.new
18
- else
19
- pg_connection.exec(query)
26
+ PgDice::LogHelper.log_duration('Executing query', logger) do
27
+ query_executor.call(query)
20
28
  end
21
29
  end
22
30
  end
23
31
 
24
32
  # Null-object pattern for PG::Result since that object isn't straightforward to initialize
25
- class PgDicePgResponse
33
+ class PgResponse
26
34
  def values
27
35
  []
28
36
  end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionManager
4
+ module PgDice
5
+ # PartitionListerFactory is a class used to build PartitionListers
6
+ class DatabaseConnectionFactory
7
+ extend Forwardable
8
+
9
+ def_delegators :@configuration, :logger, :pg_connection, :dry_run
10
+
11
+ def initialize(configuration, opts = {})
12
+ @configuration = configuration
13
+ @query_executor = opts[:query_executor] ||= ->(query) { pg_connection.exec(query) }
14
+ end
15
+
16
+ def call
17
+ PgDice::DatabaseConnection.new(logger: logger, query_executor: @query_executor, dry_run: dry_run)
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Helper used to manipulate date objects
5
+ module DateHelper
6
+ def pad_date(numbers)
7
+ return numbers if numbers.size == 8
8
+
9
+ case numbers.size
10
+ when 6
11
+ return numbers + '01'
12
+ when 4
13
+ return numbers + '0101'
14
+ else
15
+ raise ArgumentError, "Invalid date. Cannot parse date from #{numbers}"
16
+ end
17
+ end
18
+
19
+ def truncate_date(date, period)
20
+ case period
21
+ when 'year'
22
+ Date.parse("#{date.year}0101")
23
+ when 'month'
24
+ Date.parse("#{date.year}#{date.month}01")
25
+ when 'day'
26
+ date
27
+ else
28
+ raise ArgumentError, "Invalid date. Cannot parse date from #{date}"
29
+ end
30
+ end
31
+
32
+ def safe_date_builder(table_name)
33
+ matches = table_name.match(/\d+/)
34
+ raise ArgumentError, "Invalid date. Cannot parse date from #{table_name}" unless matches
35
+
36
+ Date.parse(pad_date(matches[0]))
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # PgDice parent error class
5
+ class Error < StandardError
6
+ end
7
+
8
+ # Error thrown when PgSlice returns an error code
9
+ class PgSliceError < Error
10
+ end
11
+
12
+ # Generic validation error
13
+ class ValidationError < Error
14
+ end
15
+
16
+ # Error thrown if a user tries to operate on a table that is not in the ApprovedTables object.
17
+ class IllegalTableError < ValidationError
18
+ end
19
+
20
+ # Error thrown when a user attempts to manipulate partitions on a table that is not partitioned
21
+ class TableNotPartitionedError < Error
22
+ end
23
+
24
+ # Generic error for table counts
25
+ class InsufficientTablesError < Error
26
+ def initialize(direction, table_name, expected, period, found_count)
27
+ super("Insufficient #{direction} tables exist for table: #{table_name}. "\
28
+ "Expected: #{expected} having period of: #{period} but found: #{found_count}")
29
+ end
30
+ end
31
+
32
+ # Error thrown when the count of future tables is less than the expected amount
33
+ class InsufficientFutureTablesError < InsufficientTablesError
34
+ def initialize(table_name, expected, period, found_count)
35
+ super('future', table_name, expected, period, found_count)
36
+ end
37
+ end
38
+
39
+ # Error thrown when the count of past tables is less than the expected amount
40
+ class InsufficientPastTablesError < InsufficientTablesError
41
+ def initialize(table_name, expected, period, found_count)
42
+ super('past', table_name, expected, period, found_count)
43
+ end
44
+ end
45
+
46
+ # Generic configuration error
47
+ class ConfigurationError < Error
48
+ end
49
+
50
+ # Error thrown if you call a method that requires configuration first
51
+ class NotConfiguredError < ConfigurationError
52
+ def initialize(method_name)
53
+ super("Cannot use #{method_name} before PgDice has been configured! "\
54
+ 'See README.md for configuration help.')
55
+ end
56
+ end
57
+
58
+ # Error thrown if you provide bad data in a configuration
59
+ class InvalidConfigurationError < ConfigurationError
60
+ def initialize(message)
61
+ super("PgDice is not configured properly. #{message}")
62
+ end
63
+ end
64
+
65
+ # Error thrown if the config file specified does not exist.
66
+ class MissingConfigurationFileError < ConfigurationError
67
+ def initialize(config_file)
68
+ super("File: '#{config_file}' could not be found or does not exist. Is this the correct file path?")
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # LogHelper provides a convenient wrapper block to log out the duration of an operation
5
+ module LogHelper
6
+ def blank?(string)
7
+ string.nil? || string.empty?
8
+ end
9
+
10
+ def squish(string)
11
+ string ||= ''
12
+ string.gsub(/\s+/, ' ')
13
+ end
14
+
15
+ class << self
16
+ # If you want to pass the the result of your block into the message you can use '{}' and it will be
17
+ # substituted with the result of your block.
18
+ def log_duration(message, logger, options = {})
19
+ logger.error { 'log_duration called without a block. Cannot time the duration of nothing.' } unless block_given?
20
+ time_start = Time.now.utc
21
+ result = yield
22
+ time_end = Time.now.utc
23
+
24
+ formatted_message = format_message(time_end, time_start, message, result)
25
+ logger.public_send(options[:log_level] || :debug) { formatted_message }
26
+ result
27
+ end
28
+
29
+ private
30
+
31
+ def format_message(time_end, time_start, message, result)
32
+ message = message.sub(/{}/, result.to_s)
33
+ "#{message} took: #{format('%.02f', (time_end - time_start))} seconds."
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for TableDropperHelper
4
+ module PgDice
5
+ # Simple class used to provide a mechanism that users can hook into if they want to override this
6
+ # default behavior for dropping a table.
7
+ class PartitionDropper
8
+ attr_reader :logger, :query_executor
9
+
10
+ def initialize(logger:, query_executor:)
11
+ @logger = logger
12
+ @query_executor = query_executor
13
+ end
14
+
15
+ def call(old_partitions)
16
+ logger.info { "Partitions to be deleted are: #{old_partitions}" }
17
+
18
+ query_executor.call(generate_drop_sql(old_partitions))
19
+
20
+ old_partitions
21
+ end
22
+
23
+ private
24
+
25
+ def generate_drop_sql(old_partitions)
26
+ return if old_partitions.size.zero?
27
+
28
+ sql_query = old_partitions.reduce("BEGIN;\n") do |sql, table_name|
29
+ sql + "DROP TABLE IF EXISTS #{table_name} CASCADE;\n"
30
+ end
31
+ sql_query + 'COMMIT;'
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionManager
4
+ module PgDice
5
+ # PartitionListerFactory is a class used to build PartitionListers
6
+ class PartitionDropperFactory
7
+ extend Forwardable
8
+
9
+ def_delegators :@configuration, :logger, :database_connection
10
+
11
+ def initialize(configuration, opts = {})
12
+ @configuration = configuration
13
+ @query_executor = opts[:query_executor] ||= ->(sql) { database_connection.execute(sql) }
14
+ end
15
+
16
+ def call
17
+ PgDice::PartitionDropper.new(logger: logger, query_executor: @query_executor)
18
+ end
19
+ end
20
+ end
@@ -4,49 +4,37 @@
4
4
  module PgDice
5
5
  # Helps do high-level tasks like getting tables partitioned
6
6
  class PartitionHelper
7
- extend Forwardable
8
- def_delegators :@configuration, :logger
7
+ attr_reader :logger, :approved_tables, :validation, :pg_slice_manager
9
8
 
10
- attr_reader :pg_slice_manager, :validation_helper
11
-
12
- def initialize(configuration = PgDice::Configuration.new)
13
- @configuration = configuration
14
- @pg_slice_manager = PgDice::PgSliceManager.new(configuration)
15
- @validation_helper = PgDice::Validation.new(configuration)
9
+ def initialize(logger:, approved_tables:, validation:, pg_slice_manager:)
10
+ @logger = logger
11
+ @validation = validation
12
+ @approved_tables = approved_tables
13
+ @pg_slice_manager = pg_slice_manager
16
14
  end
17
15
 
18
- def partition_table!(params = {})
19
- params[:column_name] ||= 'created_at'
20
- params[:period] ||= :day
21
-
22
- validation_helper.validate_parameters(params)
16
+ def partition_table(table_name, params = {})
17
+ table = approved_tables.fetch(table_name)
18
+ all_params = table.smash(params)
19
+ validation.validate_parameters(all_params)
23
20
 
24
- logger.info { "Preparing database with params: #{params}" }
21
+ logger.info { "Preparing database for table: #{table}. Using parameters: #{all_params}" }
25
22
 
26
- prep_and_fill(params)
27
- swap_and_fill(params)
23
+ prep_and_fill(all_params)
24
+ swap_and_fill(all_params)
28
25
  end
29
26
 
30
- def undo_partitioning!(params = {})
31
- table_name = params.fetch(:table_name)
32
-
33
- validation_helper.validate_parameters(params)
34
- logger.info { "Cleaning up database with params: #{table_name}" }
27
+ def undo_partitioning!(table_name)
28
+ approved_tables.fetch(table_name)
29
+ logger.info { "Undoing partitioning for table: #{table_name}" }
35
30
 
36
31
  pg_slice_manager.analyze(table_name: table_name, swapped: true)
37
32
  pg_slice_manager.unswap!(table_name: table_name)
38
33
  pg_slice_manager.unprep!(table_name: table_name)
39
34
  end
40
35
 
41
- def partition_table(params = {})
42
- partition_table!(params)
43
- rescue PgDice::PgSliceError => error
44
- logger.error { "Rescued PgSliceError: #{error}" }
45
- false
46
- end
47
-
48
- def undo_partitioning(params = {})
49
- undo_partitioning!(params)
36
+ def undo_partitioning(table_name)
37
+ undo_partitioning!(table_name)
50
38
  rescue PgDice::PgSliceError => error
51
39
  logger.error { "Rescued PgSliceError: #{error}" }
52
40
  false
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionManager
4
+ module PgDice
5
+ # PartitionManagerFactory is a class used to build PartitionManagers
6
+ class PartitionHelperFactory
7
+ extend Forwardable
8
+
9
+ def_delegators :@configuration, :logger, :approved_tables
10
+
11
+ def initialize(configuration, opts = {})
12
+ @configuration = configuration
13
+ @validation_factory = opts[:validation_factory] ||= PgDice::ValidationFactory.new(configuration)
14
+ @pg_slice_manager_factory = opts[:pg_slice_manager_factory] ||= PgDice::PgSliceManagerFactory.new(configuration)
15
+ end
16
+
17
+ def call
18
+ PgDice::PartitionHelper.new(logger: logger,
19
+ approved_tables: approved_tables,
20
+ validation: @validation_factory.call,
21
+ pg_slice_manager: @pg_slice_manager_factory.call)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionManager
4
+ module PgDice
5
+ # PartitionLister is used to list partitions
6
+ class PartitionLister
7
+ attr_reader :query_executor
8
+
9
+ def initialize(query_executor:)
10
+ @query_executor = query_executor
11
+ end
12
+
13
+ def call(all_params)
14
+ sql = build_partition_table_fetch_sql(all_params)
15
+ query_executor.call(sql)
16
+ end
17
+
18
+ private
19
+
20
+ def build_partition_table_fetch_sql(params = {})
21
+ schema = params.fetch(:schema, 'public')
22
+ base_table_name = params.fetch(:table_name)
23
+
24
+ <<~SQL
25
+ SELECT tablename
26
+ FROM pg_tables
27
+ WHERE schemaname = '#{schema}'
28
+ AND tablename ~ '^#{base_table_name}_\\d+$'
29
+ ORDER BY tablename;
30
+ SQL
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Entry point for PartitionManager
4
+ module PgDice
5
+ # PartitionListerFactory is a class used to build PartitionListers
6
+ class PartitionListerFactory
7
+ extend Forwardable
8
+
9
+ def_delegators :@configuration, :database_connection
10
+
11
+ def initialize(configuration, opts = {})
12
+ @configuration = configuration
13
+ @query_executor = opts[:query_executor] ||= lambda do |sql|
14
+ database_connection.execute(sql).values.flatten
15
+ end
16
+ end
17
+
18
+ def call
19
+ PgDice::PartitionLister.new(query_executor: @query_executor)
20
+ end
21
+ end
22
+ end
@@ -4,83 +4,87 @@
4
4
  module PgDice
5
5
  # PartitionManager is a class used to fulfill high-level tasks for partitioning
6
6
  class PartitionManager
7
- extend Forwardable
8
- def_delegators :@configuration, :logger, :older_than, :table_drop_batch_size
9
-
10
- attr_reader :validation, :pg_slice_manager, :database_connection
11
-
12
- def initialize(configuration = PgDice::Configuration.new)
13
- @configuration = configuration
14
- @validation = PgDice::Validation.new(configuration)
15
- @pg_slice_manager = PgDice::PgSliceManager.new(configuration)
16
- @database_connection = PgDice::DatabaseConnection.new(configuration)
7
+ include PgDice::TableFinder
8
+
9
+ attr_reader :logger, :batch_size, :validation, :approved_tables, :partition_adder,
10
+ :partition_lister, :partition_dropper, :current_date_provider
11
+
12
+ def initialize(opts = {})
13
+ @logger = opts.fetch(:logger)
14
+ @batch_size = opts.fetch(:batch_size)
15
+ @validation = opts.fetch(:validation)
16
+ @approved_tables = opts.fetch(:approved_tables)
17
+ @partition_adder = opts.fetch(:partition_adder)
18
+ @partition_lister = opts.fetch(:partition_lister)
19
+ @partition_dropper = opts.fetch(:partition_dropper)
20
+ @current_date_provider = opts.fetch(:current_date_provider, proc { Time.now.utc.to_date })
17
21
  end
18
22
 
19
- def add_new_partitions(params = {})
20
- logger.info { "add_new_partitions has been called with params: #{params}" }
21
-
22
- validation.validate_parameters(params)
23
- pg_slice_manager.add_partitions(params)
23
+ def add_new_partitions(table_name, params = {})
24
+ all_params = approved_tables.smash(table_name, params)
25
+ logger.debug { "add_new_partitions has been called with params: #{all_params}" }
26
+ validation.validate_parameters(all_params)
27
+ partition_adder.call(all_params)
24
28
  end
25
29
 
26
- def drop_old_partitions(params = {})
27
- logger.info { "drop_old_partitions has been called with params: #{params}" }
30
+ def drop_old_partitions(table_name, params = {})
31
+ all_params = approved_tables.smash(table_name, params)
32
+ all_params[:older_than] = current_date_provider.call
33
+ logger.debug { "drop_old_partitions has been called with params: #{all_params}" }
28
34
 
29
- validation.validate_parameters(params)
30
- old_partitions = list_old_partitions(params)
31
- logger.warn { "Partitions to be deleted are: #{old_partitions}" }
35
+ validation.validate_parameters(all_params)
36
+ drop_partitions(all_params)
37
+ end
32
38
 
33
- old_partitions.each do |old_partition|
34
- @configuration.table_dropper.call(old_partition, logger)
35
- end
36
- old_partitions
39
+ # Grabs only tables that start with the base_table_name and end in numbers
40
+ def list_partitions(table_name, params = {})
41
+ all_params = approved_tables.smash(table_name, params)
42
+ validation.validate_parameters(all_params)
43
+ partitions(all_params)
37
44
  end
38
45
 
39
- def list_old_partitions(params = {})
40
- params[:older_than] ||= older_than
41
- logger.info { "Listing old partitions with params: #{params}" }
46
+ def list_droppable_partitions(table_name, params = {})
47
+ all_params = approved_tables.smash(table_name, params)
48
+ validation.validate_parameters(all_params)
49
+ droppable_partitions(all_params)
50
+ end
42
51
 
43
- validation.validate_parameters(params)
52
+ def list_batched_droppable_partitions(table_name, params = {})
53
+ all_params = approved_tables.smash(table_name, params)
54
+ validation.validate_parameters(all_params)
55
+ droppable_tables = batched_droppable_partitions(all_params)
56
+ logger.debug { "Batched partitions eligible for dropping are: #{droppable_tables}" }
57
+ droppable_tables
58
+ end
44
59
 
45
- partition_tables = fetch_partition_tables(params)
60
+ private
46
61
 
47
- filter_partitions(partition_tables, params[:table_name], params[:older_than])
62
+ def partitions(all_params)
63
+ logger.info { "Fetching partition tables with params: #{all_params}" }
64
+ partition_lister.call(all_params)
48
65
  end
49
66
 
50
- # Grabs only tables that start with the base_table_name and end in numbers
51
- def fetch_partition_tables(params = {})
52
- schema = params[:schema] ||= 'public'
53
- logger.info { "Fetching partition tables with params: #{params}" }
67
+ def droppable_partitions(all_params)
68
+ older_than = current_date_provider.call
69
+ minimum_tables = all_params.fetch(:past)
70
+ period = all_params.fetch(:period)
54
71
 
55
- sql = build_partition_table_fetch_sql(params)
72
+ eligible_partitions = partitions(all_params)
56
73
 
57
- partition_tables = database_connection.execute(sql).values.flatten
58
- logger.debug { "Table: #{schema}.#{params[:table_name]} has partition_tables: #{partition_tables}" }
59
- partition_tables
74
+ droppable_tables = find_droppable_partitions(eligible_partitions, older_than, minimum_tables, period)
75
+ logger.debug { "Partitions eligible for dropping older than: #{older_than} are: #{droppable_tables}" }
76
+ droppable_tables
60
77
  end
61
78
 
62
- private
63
-
64
- def filter_partitions(partition_tables, base_table_name, partitions_older_than_time)
65
- partition_tables.select do |partition_name|
66
- partition_created_at_date = Date.parse(partition_name.gsub(/#{base_table_name}_/, '')).to_time
67
- partition_created_at_date < partitions_older_than_time
68
- end
79
+ def batched_droppable_partitions(all_params)
80
+ max_tables_to_drop_at_once = all_params.fetch(:batch_size, batch_size)
81
+ selected_partitions = droppable_partitions(all_params)
82
+ batched_tables(selected_partitions, max_tables_to_drop_at_once)
69
83
  end
70
84
 
71
- def build_partition_table_fetch_sql(params = {})
72
- schema = params.fetch(:schema)
73
- base_table_name = params.fetch(:table_name)
74
- limit = params.fetch(:limit, table_drop_batch_size)
75
-
76
- <<~SQL
77
- SELECT tablename
78
- FROM pg_tables
79
- WHERE schemaname = '#{schema}'
80
- AND tablename ~ '^#{base_table_name}_\\d+$'
81
- ORDER BY tablename
82
- LIMIT #{limit}
83
- SQL
85
+ def drop_partitions(all_params)
86
+ old_partitions = batched_droppable_partitions(all_params)
87
+ partition_dropper.call(old_partitions)
84
88
  end
85
89
  end
86
90
  end