pgdice 0.1.0 → 0.2.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.
@@ -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