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,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