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,52 @@
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 PartitionManagerFactory
7
+ def initialize(configuration, opts = {})
8
+ @configuration = configuration
9
+ initialize_simple_factories(opts)
10
+ initialize_complex_factories(opts)
11
+ initialize_values(opts)
12
+ end
13
+
14
+ def call
15
+ PgDice::PartitionManager.new(logger: @logger_factory.call,
16
+ batch_size: @batch_size_factory.call,
17
+ approved_tables: @approved_tables_factory.call,
18
+ validation: @validation_factory.call,
19
+ partition_adder: @partition_adder_factory.call,
20
+ partition_lister: @partition_lister_factory.call,
21
+ partition_dropper: @partition_dropper_factory.call,
22
+ current_date_provider: @current_date_provider)
23
+ end
24
+
25
+ private
26
+
27
+ def initialize_simple_factories(opts)
28
+ @logger_factory = opts[:logger_factory] ||= proc { @configuration.logger }
29
+ @batch_size_factory = opts[:batch_size_factory] ||= proc { @configuration.batch_size }
30
+ @approved_tables_factory = opts[:approved_tables_factory] ||= proc { @configuration.approved_tables }
31
+ end
32
+
33
+ def initialize_complex_factories(opts)
34
+ @validation_factory = opts[:validation_factory] ||= PgDice::ValidationFactory.new(@configuration)
35
+ @partition_adder_factory = opts[:partition_adder_factory] ||= partition_adder_factory
36
+ @partition_lister_factory = opts[:partition_lister_factory] ||= PgDice::PartitionListerFactory.new(@configuration)
37
+ @partition_dropper_factory =
38
+ opts[:partition_dropper_factory] ||= PgDice::PartitionDropperFactory.new(@configuration)
39
+ end
40
+
41
+ def initialize_values(opts)
42
+ @current_date_provider = opts[:current_date_provider] ||= proc { Time.now.utc.to_date }
43
+ end
44
+
45
+ def partition_adder_factory
46
+ proc do
47
+ pg_slice_manager = PgDice::PgSliceManagerFactory.new(@configuration).call
48
+ ->(all_params) { pg_slice_manager.add_partitions(all_params) }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Used to find the period of a postgres table using the comment on the table created by pgslice
5
+ class PeriodFetcher
6
+ def initialize(query_executor:)
7
+ @query_executor = query_executor
8
+ end
9
+
10
+ def call(params)
11
+ sql = build_table_comment_sql(params.fetch(:table_name), params.fetch(:schema))
12
+ values = @query_executor.call(sql)
13
+ convert_comment_to_hash(values.first)[:period]
14
+ end
15
+
16
+ private
17
+
18
+ def convert_comment_to_hash(comment)
19
+ return {} unless comment
20
+
21
+ comment.split(',').reduce({}) do |hash, key_value_pair|
22
+ key, value = key_value_pair.split(':')
23
+ hash.merge(key.to_sym => value)
24
+ end
25
+ end
26
+
27
+ def build_table_comment_sql(table_name, schema)
28
+ "SELECT obj_description('#{schema}.#{table_name}'::REGCLASS) AS comment"
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Factory for PeriodFetcher
5
+ class PeriodFetcherFactory
6
+ extend Forwardable
7
+
8
+ def_delegators :@configuration, :database_connection
9
+
10
+ def initialize(configuration, opts = {})
11
+ @configuration = configuration
12
+ @query_executor = opts[:query_executor] ||= ->(sql) { database_connection.execute(sql).values.flatten.compact }
13
+ end
14
+
15
+ def call
16
+ PgDice::PeriodFetcher.new(query_executor: @query_executor)
17
+ end
18
+ end
19
+ end
@@ -4,18 +4,22 @@
4
4
  module PgDice
5
5
  # PgSliceManager is a wrapper around PgSlice
6
6
  class PgSliceManager
7
- extend Forwardable
8
- def_delegators :@configuration, :logger, :database_url, :dry_run
7
+ include PgDice::LogHelper
9
8
 
10
- def initialize(configuration = PgDice::Configuration.new)
11
- @configuration = configuration
9
+ attr_reader :logger, :database_url
10
+
11
+ def initialize(logger:, database_url:, pg_slice_executor:, dry_run: false)
12
+ @logger = logger
13
+ @database_url = database_url
14
+ @dry_run = dry_run
15
+ @pg_slice_executor = pg_slice_executor
12
16
  end
13
17
 
14
18
  def prep(params = {})
15
19
  table_name = params.fetch(:table_name)
16
20
  column_name = params.fetch(:column_name)
17
21
  period = params.fetch(:period)
18
- run_pgslice("prep #{table_name} #{column_name} #{period}")
22
+ run_pgslice("prep #{table_name} #{column_name} #{period}", params[:dry_run])
19
23
  end
20
24
 
21
25
  def fill(params = {})
@@ -23,7 +27,7 @@ module PgDice
23
27
  swapped = params.fetch(:swapped, '')
24
28
  swapped = '--swapped' if swapped.to_s.casecmp('true').zero?
25
29
 
26
- run_pgslice("fill #{table_name} #{swapped}")
30
+ run_pgslice("fill #{table_name} #{swapped}", params[:dry_run])
27
31
  end
28
32
 
29
33
  def analyze(params = {})
@@ -31,12 +35,12 @@ module PgDice
31
35
  swapped = params.fetch(:swapped, '')
32
36
  swapped = '--swapped' if swapped.to_s.casecmp('true').zero?
33
37
 
34
- run_pgslice("analyze #{table_name} #{swapped}")
38
+ run_pgslice("analyze #{table_name} #{swapped}", params[:dry_run])
35
39
  end
36
40
 
37
41
  def swap(params = {})
38
42
  table_name = params.fetch(:table_name)
39
- run_pgslice("swap #{table_name}")
43
+ run_pgslice("swap #{table_name}", params[:dry_run])
40
44
  end
41
45
 
42
46
  def add_partitions(params = {})
@@ -50,25 +54,25 @@ module PgDice
50
54
  intermediate = params.fetch(:intermediate, nil)
51
55
  intermediate = '--intermediate' if intermediate.to_s.casecmp('true').zero?
52
56
 
53
- run_pgslice("add_partitions #{table_name} #{intermediate} #{future_tables} #{past_tables}")
57
+ run_pgslice("add_partitions #{table_name} #{intermediate} #{future_tables} #{past_tables}", params[:dry_run])
54
58
  end
55
59
 
56
60
  def unprep!(params = {})
57
61
  table_name = params.fetch(:table_name)
58
62
 
59
- run_pgslice("unprep #{table_name}")
63
+ run_pgslice("unprep #{table_name}", params[:dry_run])
60
64
  end
61
65
 
62
66
  def unswap!(params = {})
63
67
  table_name = params.fetch(:table_name)
64
68
 
65
- run_pgslice("unswap #{table_name}")
69
+ run_pgslice("unswap #{table_name}", params[:dry_run])
66
70
  end
67
71
 
68
72
  def unprep(params = {})
69
73
  table_name = params.fetch(:table_name)
70
74
 
71
- run_pgslice("unprep #{table_name}")
75
+ run_pgslice("unprep #{table_name}", params[:dry_run])
72
76
  rescue PgSliceError => error
73
77
  logger.error { "Rescued PgSliceError: #{error}" }
74
78
  false
@@ -77,7 +81,7 @@ module PgDice
77
81
  def unswap(params = {})
78
82
  table_name = params.fetch(:table_name)
79
83
 
80
- run_pgslice("unswap #{table_name}")
84
+ run_pgslice("unswap #{table_name}", params[:dry_run])
81
85
  rescue PgSliceError => error
82
86
  logger.error { "Rescued PgSliceError: #{error}" }
83
87
  false
@@ -85,35 +89,46 @@ module PgDice
85
89
 
86
90
  private
87
91
 
88
- def run_pgslice(argument_string)
89
- parameters = build_pg_slice_command(argument_string)
92
+ def run_pgslice(argument_string, dry_run)
93
+ command = build_pg_slice_command(argument_string, dry_run)
90
94
 
91
- stdout, stderr, status = Open3.capture3(parameters)
92
- log_result(stdout, stderr, status)
95
+ stdout, stderr, status = run_and_log(command)
93
96
 
94
- if status.exitstatus.to_i.positive?
97
+ if status.to_i.positive?
95
98
  raise PgDice::PgSliceError,
96
- "pgslice with arguments: '#{argument_string}' failed with status: '#{status.exitstatus}' "\
99
+ "pgslice with arguments: '#{argument_string}' failed with status: '#{status}' "\
97
100
  "STDOUT: '#{stdout}' STDERR: '#{stderr}'"
98
101
  end
99
102
  true
100
103
  end
101
104
 
102
- def build_pg_slice_command(argument_string)
105
+ def build_pg_slice_command(argument_string, dry_run)
103
106
  argument_string = argument_string.strip
104
- logger.info { "Running pgslice command: '#{argument_string}'" }
105
107
  $stdout.flush
106
108
  $stderr.flush
107
- command = "pgslice #{argument_string} "
108
- command += '--dry-run true ' if dry_run
109
- command += "--url #{database_url}"
110
- command
109
+ command = "pgslice #{argument_string}"
110
+ command += ' --dry-run true' if @dry_run || dry_run
111
+ command = squish(command)
112
+ logger.info { "Running pgslice command: '#{command}'" }
113
+ command + " --url #{database_url}"
114
+ end
115
+
116
+ def log_result(stdout, stderr)
117
+ logger.debug { "pgslice STDERR: #{stderr}" } unless blank?(stderr)
118
+ logger.debug { "pgslice STDOUT: #{stdout}" } unless blank?(stdout)
111
119
  end
112
120
 
113
- def log_result(stdout, stderr, status)
114
- logger.debug { "pgslice STDERR: #{stderr}" } if stderr
115
- logger.debug { "pgslice STDOUT: #{stdout}" } if stdout
116
- logger.debug { "pgslice exit status: #{status.exitstatus}" } if status
121
+ def log_status(status)
122
+ logger.debug { "pgslice exit status: #{status}" } unless blank?(status) || status.to_i.zero?
123
+ end
124
+
125
+ def run_and_log(command)
126
+ PgDice::LogHelper.log_duration('PgSlice', logger) do
127
+ stdout, stderr, status = @pg_slice_executor.call(command)
128
+ log_result(stdout, stderr)
129
+ log_status(status)
130
+ [stdout, stderr, status]
131
+ end
117
132
  end
118
133
  end
119
134
  end
@@ -0,0 +1,35 @@
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 PgSliceManagerFactory
7
+ extend Forwardable
8
+ include PgDice::LogHelper
9
+
10
+ def_delegators :@configuration, :logger, :database_url, :dry_run
11
+
12
+ def initialize(configuration, opts = {})
13
+ @configuration = configuration
14
+ @pg_slice_executor = opts[:pg_slice_executor] ||= executor
15
+ end
16
+
17
+ def call
18
+ PgDice::PgSliceManager.new(logger: logger,
19
+ database_url: database_url,
20
+ pg_slice_executor: @pg_slice_executor,
21
+ dry_run: dry_run)
22
+ end
23
+
24
+ private
25
+
26
+ def executor
27
+ lambda do |command|
28
+ results = Open3.capture3(command)
29
+ stdout, stderr = results.first(2).map { |output| squish(output.to_s) }
30
+ status = results[2].exitstatus.to_s
31
+ [stdout, stderr, status]
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Object to represent a table's configuration in the context of PgDice.
5
+ class Table
6
+ include Comparable
7
+ attr_reader :table_name
8
+ attr_accessor :past, :future, :column_name, :period, :schema
9
+
10
+ def initialize(table_name:, past: 90, future: 7, column_name: 'created_at', period: 'day', schema: 'public')
11
+ raise ArgumentError, 'table_name must be a string' unless table_name.is_a?(String)
12
+
13
+ @table_name = table_name
14
+ @past = past
15
+ @future = future
16
+ @column_name = column_name
17
+ @period = period
18
+ @schema = schema
19
+ end
20
+
21
+ def validate!
22
+ check_type(:past, Integer)
23
+ check_type(:future, Integer)
24
+ check_type(:column_name, String)
25
+ check_type(:period, String)
26
+ check_type(:schema, String)
27
+ unless PgDice::SUPPORTED_PERIODS.include?(period)
28
+ raise ArgumentError,
29
+ "Period must be one of: #{PgDice::SUPPORTED_PERIODS.keys}. Value: #{period} is not valid."
30
+ end
31
+ true
32
+ end
33
+
34
+ def name
35
+ table_name
36
+ end
37
+
38
+ def full_name
39
+ "#{schema}.#{name}"
40
+ end
41
+
42
+ def to_h
43
+ { table_name: table_name,
44
+ past: past,
45
+ future: future,
46
+ column_name: column_name,
47
+ period: period,
48
+ schema: schema }
49
+ end
50
+
51
+ def to_s
52
+ "#{schema}.#{name}: <past: #{past}, future: #{future}, column_name: #{column_name}, period: #{period}>"
53
+ end
54
+
55
+ def smash(override_parameters)
56
+ to_h.merge!(override_parameters)
57
+ end
58
+
59
+ def ==(other)
60
+ to_h == other.to_h
61
+ end
62
+
63
+ def <=>(other)
64
+ table_name <=> other.table_name
65
+ end
66
+
67
+ # Get expected size of this configured table (past + present + future table counts)
68
+ def size
69
+ past + future + 1
70
+ end
71
+
72
+ def self.from_hash(hash)
73
+ Table.new(**hash.each_with_object({}) { |(k, v), memo| memo[k.to_sym] = v; })
74
+ end
75
+
76
+ private
77
+
78
+ def check_type(field, expected_type)
79
+ unless send(field).is_a?(expected_type)
80
+ raise ArgumentError,
81
+ "PgDice::Table: #{name} failed validation on field: #{field}. "\
82
+ "Expected type of: #{expected_type} but found #{send(field).class}"
83
+ end
84
+ true
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Module which is a collection of methods used by PartitionManager to find and list tables
5
+ module TableFinder
6
+ include PgDice::DateHelper
7
+
8
+ def find_droppable_partitions(all_tables, older_than, minimum_tables, period)
9
+ tables_older_than = tables_older_than(all_tables, older_than, period)
10
+ tables_to_grab = tables_to_grab(tables_older_than.size, minimum_tables)
11
+ tables_older_than.first(tables_to_grab)
12
+ end
13
+
14
+ def tables_to_grab(eligible_tables, minimum_tables)
15
+ tables_to_grab = eligible_tables - minimum_tables
16
+ tables_to_grab.positive? ? tables_to_grab : 0
17
+ end
18
+
19
+ def batched_tables(tables, batch_size)
20
+ tables.first(batch_size)
21
+ end
22
+
23
+ def tables_older_than(tables, older_than, period)
24
+ table_tester(tables, lambda do |partition_created_at_time|
25
+ partition_created_at_time < truncate_date(older_than.to_date, period)
26
+ end)
27
+ end
28
+
29
+ def tables_newer_than(tables, newer_than, period)
30
+ table_tester(tables, lambda do |partition_created_at_time|
31
+ partition_created_at_time > truncate_date(newer_than.to_date, period)
32
+ end)
33
+ end
34
+
35
+ def table_tester(tables, predicate)
36
+ tables.select do |partition_name|
37
+ predicate.call(safe_date_builder(partition_name))
38
+ end
39
+ end
40
+ end
41
+ end
@@ -3,23 +3,27 @@
3
3
  module PgDice
4
4
  # Collection of utilities that provide ways for users to ensure things are working properly
5
5
  class Validation
6
- extend Forwardable
7
- def_delegators :@configuration, :logger, :additional_validators, :database_connection, :approved_tables
6
+ include PgDice::TableFinder
8
7
 
9
- def initialize(configuration = PgDice::Configuration.new)
10
- @configuration = configuration
8
+ attr_reader :logger, :approved_tables
9
+
10
+ def initialize(logger:, partition_lister:, period_fetcher:, approved_tables:, current_date_provider:)
11
+ @logger = logger
12
+ @approved_tables = approved_tables
13
+ @partition_lister = partition_lister
14
+ @period_fetcher = period_fetcher
15
+ @current_date_provider = current_date_provider
11
16
  end
12
17
 
13
- def assert_tables(params = {})
14
- unless params[:future] || params[:past]
15
- raise ArgumentError, 'You must provide either a future or past number of tables to assert on.'
16
- end
18
+ def assert_tables(table_name, opts = nil)
19
+ table, period, all_params, params = filter_parameters(approved_tables.fetch(table_name), opts)
20
+ validate_parameters(all_params)
21
+ logger.debug { "Running asserts on table: #{table} with params: #{all_params}" }
17
22
 
18
- table_name = validate_table_name(params)
19
- period = resolve_period(params)
23
+ partitions = @partition_lister.call(all_params)
20
24
 
21
- assert_future_tables(table_name, params[:future], period) if params[:future]
22
- assert_past_tables(table_name, params[:past], period) if params[:past]
25
+ assert_future_tables(table_name, partitions, period, params[:future]) if params && params[:future]
26
+ assert_past_tables(table_name, partitions, period, params[:past]) if params && params[:past]
23
27
  true
24
28
  end
25
29
 
@@ -27,14 +31,46 @@ module PgDice
27
31
  validate_table_name(params)
28
32
  validate_period(params)
29
33
 
30
- run_additional_validators(params)
31
34
  true
32
35
  end
33
36
 
34
37
  private
35
38
 
39
+ def filter_parameters(table, params)
40
+ if params.nil?
41
+ params = {}
42
+ params[:future] = table.future
43
+ params[:past] = table.past
44
+ period = table.period
45
+ else
46
+ period = resolve_period(schema: table.schema, table_name: table.name, **params)
47
+ end
48
+ all_params = table.smash(params.merge!(period: period))
49
+
50
+ [table, period, all_params, params]
51
+ end
52
+
53
+ def assert_future_tables(table_name, partitions, period, expected)
54
+ newer_tables = tables_newer_than(partitions, @current_date_provider.call, period).size
55
+ if newer_tables < expected
56
+ raise PgDice::InsufficientFutureTablesError.new(table_name, expected, period, newer_tables)
57
+ end
58
+
59
+ true
60
+ end
61
+
62
+ def assert_past_tables(table_name, partitions, period, expected)
63
+ older_tables = tables_older_than(partitions, @current_date_provider.call, period).size
64
+ if older_tables < expected
65
+ raise PgDice::InsufficientPastTablesError.new(table_name, expected, period, older_tables)
66
+ end
67
+
68
+ true
69
+ end
70
+
36
71
  def resolve_period(params)
37
- period = validate_period(params) || fetch_period_from_table_comment(params.fetch(:table_name))
72
+ validate_period(params) if params[:period]
73
+ period = @period_fetcher.call(params)
38
74
 
39
75
  # If the user doesn't supply a period and we fail to find one on the table then it's a pretty good bet
40
76
  # this table is not partitioned at all.
@@ -42,17 +78,10 @@ module PgDice
42
78
  raise TableNotPartitionedError,
43
79
  "Table: #{params.fetch(:table_name)} is not partitioned! Cannot validate partitions that don't exist!"
44
80
  end
81
+ validate_period(period: period)
45
82
  period
46
83
  end
47
84
 
48
- def run_additional_validators(params)
49
- return true if additional_validators.all? { |validator| validator.call(params, logger) }
50
-
51
- raise PgDice::CustomValidationError.new(params, additional_validators)
52
- rescue StandardError => error
53
- raise PgDice::CustomValidationError.new(params, additional_validators, error)
54
- end
55
-
56
85
  def validate_table_name(params)
57
86
  table_name = params.fetch(:table_name)
58
87
  unless approved_tables.include?(table_name)
@@ -65,68 +94,12 @@ module PgDice
65
94
  def validate_period(params)
66
95
  return unless params[:period]
67
96
 
68
- unless PgDice::SUPPORTED_PERIODS.include?(params[:period])
97
+ unless PgDice::SUPPORTED_PERIODS.include?(params[:period].to_s)
69
98
  raise ArgumentError,
70
99
  "Period must be one of: #{PgDice::SUPPORTED_PERIODS.keys}. Value: #{params[:period]} is not valid."
71
100
  end
72
101
 
73
102
  params[:period].to_sym
74
103
  end
75
-
76
- def assert_future_tables(table_name, future, period)
77
- sql = build_assert_sql(table_name, future, period, :future)
78
-
79
- response = database_connection.execute(sql)
80
-
81
- return true if response.values.size == 1
82
-
83
- raise PgDice::InsufficientFutureTablesError.new(table_name, future, period)
84
- end
85
-
86
- def assert_past_tables(table_name, past, period)
87
- sql = build_assert_sql(table_name, past, period, :past)
88
-
89
- response = database_connection.execute(sql)
90
-
91
- return true if response.values.size == 1
92
-
93
- raise PgDice::InsufficientPastTablesError.new(table_name, past, period)
94
- end
95
-
96
- def build_assert_sql(table_name, table_count, period, direction)
97
- add_or_subtract = { future: '+', past: '-' }.fetch(direction, '+')
98
- <<~SQL
99
- SELECT 1
100
- FROM pg_catalog.pg_class pg_class
101
- INNER JOIN pg_catalog.pg_namespace pg_namespace ON pg_namespace.oid = pg_class.relnamespace
102
- WHERE pg_class.relkind = 'r'
103
- AND pg_namespace.nspname = 'public'
104
- AND pg_class.relname = '#{table_name}_' || to_char(NOW()
105
- #{add_or_subtract} INTERVAL '#{table_count} #{period}', '#{SUPPORTED_PERIODS[period.to_sym]}')
106
- SQL
107
- end
108
-
109
- def fetch_period_from_table_comment(table_name)
110
- sql = build_table_comment_sql(table_name, 'public')
111
- values = database_connection.execute(sql).values.flatten.compact
112
- convert_comment_to_hash(values.first)[:period]
113
- end
114
-
115
- def convert_comment_to_hash(comment)
116
- return {} unless comment
117
-
118
- partition_template = {}
119
- comment.split(',').each do |key_value_pair|
120
- key, value = key_value_pair.split(':')
121
- partition_template[key.to_sym] = value
122
- end
123
- partition_template
124
- end
125
-
126
- def build_table_comment_sql(table_name, schema)
127
- <<~SQL
128
- SELECT obj_description('#{schema}.#{table_name}'::REGCLASS) AS comment
129
- SQL
130
- end
131
104
  end
132
105
  end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PgDice
4
+ # Factory for PgDice::Validations
5
+ class ValidationFactory
6
+ def initialize(configuration, opts = {})
7
+ @configuration = configuration
8
+ @partition_lister_factory = opts[:partition_lister_factory] ||= PgDice::PartitionListerFactory.new(@configuration)
9
+ @period_fetcher_factory = opts[:period_fetcher_factory] ||= PgDice::PeriodFetcherFactory.new(@configuration)
10
+ @current_date_provider = opts[:current_date_provider] ||= proc { Time.now.utc.to_date }
11
+ end
12
+
13
+ def call
14
+ PgDice::Validation.new(logger: @configuration.logger,
15
+ partition_lister: @partition_lister_factory.call,
16
+ period_fetcher: @period_fetcher_factory.call,
17
+ approved_tables: @configuration.approved_tables,
18
+ current_date_provider: @current_date_provider)
19
+ end
20
+ end
21
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module PgDice
4
- VERSION = '0.1.0'
4
+ VERSION = '0.2.0'
5
5
  end