pgslice 0.4.1 → 0.4.6
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 +4 -4
- data/CHANGELOG.md +47 -21
- data/LICENSE.txt +1 -1
- data/README.md +107 -131
- data/exe/pgslice +4 -7
- data/lib/pgslice.rb +18 -723
- data/lib/pgslice/cli.rb +32 -0
- data/lib/pgslice/cli/add_partitions.rb +130 -0
- data/lib/pgslice/cli/analyze.rb +13 -0
- data/lib/pgslice/cli/fill.rb +103 -0
- data/lib/pgslice/cli/prep.rb +97 -0
- data/lib/pgslice/cli/swap.rb +28 -0
- data/lib/pgslice/cli/unprep.rb +18 -0
- data/lib/pgslice/cli/unswap.rb +25 -0
- data/lib/pgslice/helpers.rb +176 -0
- data/lib/pgslice/table.rb +196 -0
- data/lib/pgslice/version.rb +1 -1
- metadata +20 -18
- data/.gitignore +0 -9
- data/.travis.yml +0 -13
- data/Dockerfile +0 -3
- data/Gemfile +0 -4
- data/Rakefile +0 -11
- data/pgslice.gemspec +0 -27
data/lib/pgslice/cli.rb
ADDED
@@ -0,0 +1,32 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI < Thor
|
3
|
+
class << self
|
4
|
+
attr_accessor :instance
|
5
|
+
end
|
6
|
+
|
7
|
+
include Helpers
|
8
|
+
|
9
|
+
check_unknown_options!
|
10
|
+
|
11
|
+
class_option :url, desc: "Database URL"
|
12
|
+
class_option :dry_run, type: :boolean, default: false, desc: "Print statements without executing"
|
13
|
+
|
14
|
+
map %w[--version -v] => :version
|
15
|
+
|
16
|
+
def self.exit_on_failure?
|
17
|
+
ENV["PGSLICE_ENV"] != "test"
|
18
|
+
end
|
19
|
+
|
20
|
+
def initialize(*args)
|
21
|
+
PgSlice::CLI.instance = self
|
22
|
+
$stdout.sync = true
|
23
|
+
$stderr.sync = true
|
24
|
+
super
|
25
|
+
end
|
26
|
+
|
27
|
+
desc "version", "Show version"
|
28
|
+
def version
|
29
|
+
log("pgslice #{PgSlice::VERSION}")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,130 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "add_partitions TABLE", "Add partitions"
|
4
|
+
option :intermediate, type: :boolean, default: false, desc: "Add to intermediate table"
|
5
|
+
option :past, type: :numeric, default: 0, desc: "Number of past partitions to add"
|
6
|
+
option :future, type: :numeric, default: 0, desc: "Number of future partitions to add"
|
7
|
+
def add_partitions(table)
|
8
|
+
original_table = create_table(table)
|
9
|
+
table = options[:intermediate] ? original_table.intermediate_table : original_table
|
10
|
+
trigger_name = original_table.trigger_name
|
11
|
+
|
12
|
+
assert_table(table)
|
13
|
+
|
14
|
+
future = options[:future]
|
15
|
+
past = options[:past]
|
16
|
+
range = (-1 * past)..future
|
17
|
+
|
18
|
+
period, field, cast, needs_comment, declarative, version = table.fetch_settings(original_table.trigger_name)
|
19
|
+
unless period
|
20
|
+
message = "No settings found: #{table}"
|
21
|
+
message = "#{message}\nDid you mean to use --intermediate?" unless options[:intermediate]
|
22
|
+
abort message
|
23
|
+
end
|
24
|
+
|
25
|
+
queries = []
|
26
|
+
|
27
|
+
if needs_comment
|
28
|
+
queries << "COMMENT ON TRIGGER #{quote_ident(trigger_name)} ON #{quote_table(table)} is 'column:#{field},period:#{period},cast:#{cast}';"
|
29
|
+
end
|
30
|
+
|
31
|
+
# today = utc date
|
32
|
+
today = round_date(Time.now.utc.to_date, period)
|
33
|
+
|
34
|
+
schema_table =
|
35
|
+
if !declarative
|
36
|
+
table
|
37
|
+
elsif options[:intermediate]
|
38
|
+
original_table
|
39
|
+
else
|
40
|
+
table.partitions.last
|
41
|
+
end
|
42
|
+
|
43
|
+
# indexes automatically propagate in Postgres 11+
|
44
|
+
if version < 3
|
45
|
+
index_defs = schema_table.index_defs
|
46
|
+
fk_defs = schema_table.foreign_keys
|
47
|
+
else
|
48
|
+
index_defs = []
|
49
|
+
fk_defs = []
|
50
|
+
end
|
51
|
+
|
52
|
+
primary_key = schema_table.primary_key
|
53
|
+
|
54
|
+
added_partitions = []
|
55
|
+
range.each do |n|
|
56
|
+
day = advance_date(today, period, n)
|
57
|
+
|
58
|
+
partition = Table.new(original_table.schema, "#{original_table.name}_#{day.strftime(name_format(period))}")
|
59
|
+
next if partition.exists?
|
60
|
+
added_partitions << partition
|
61
|
+
|
62
|
+
if declarative
|
63
|
+
queries << <<-SQL
|
64
|
+
CREATE TABLE #{quote_table(partition)} PARTITION OF #{quote_table(table)} FOR VALUES FROM (#{sql_date(day, cast, false)}) TO (#{sql_date(advance_date(day, period, 1), cast, false)});
|
65
|
+
SQL
|
66
|
+
else
|
67
|
+
queries << <<-SQL
|
68
|
+
CREATE TABLE #{quote_table(partition)}
|
69
|
+
(CHECK (#{quote_ident(field)} >= #{sql_date(day, cast)} AND #{quote_ident(field)} < #{sql_date(advance_date(day, period, 1), cast)}))
|
70
|
+
INHERITS (#{quote_table(table)});
|
71
|
+
SQL
|
72
|
+
end
|
73
|
+
|
74
|
+
queries << "ALTER TABLE #{quote_table(partition)} ADD PRIMARY KEY (#{primary_key.map { |k| quote_ident(k) }.join(", ")});" if primary_key.any?
|
75
|
+
|
76
|
+
index_defs.each do |index_def|
|
77
|
+
queries << make_index_def(index_def, partition)
|
78
|
+
end
|
79
|
+
|
80
|
+
fk_defs.each do |fk_def|
|
81
|
+
queries << make_fk_def(fk_def, partition)
|
82
|
+
end
|
83
|
+
end
|
84
|
+
|
85
|
+
unless declarative
|
86
|
+
# update trigger based on existing partitions
|
87
|
+
current_defs = []
|
88
|
+
future_defs = []
|
89
|
+
past_defs = []
|
90
|
+
name_format = self.name_format(period)
|
91
|
+
partitions = (table.partitions + added_partitions).uniq(&:name).sort_by(&:name)
|
92
|
+
|
93
|
+
partitions.each do |partition|
|
94
|
+
day = partition_date(partition, name_format)
|
95
|
+
|
96
|
+
sql = "(NEW.#{quote_ident(field)} >= #{sql_date(day, cast)} AND NEW.#{quote_ident(field)} < #{sql_date(advance_date(day, period, 1), cast)}) THEN
|
97
|
+
INSERT INTO #{quote_table(partition)} VALUES (NEW.*);"
|
98
|
+
|
99
|
+
if day.to_date < today
|
100
|
+
past_defs << sql
|
101
|
+
elsif advance_date(day, period, 1) < today
|
102
|
+
current_defs << sql
|
103
|
+
else
|
104
|
+
future_defs << sql
|
105
|
+
end
|
106
|
+
end
|
107
|
+
|
108
|
+
# order by current period, future periods asc, past periods desc
|
109
|
+
trigger_defs = current_defs + future_defs + past_defs.reverse
|
110
|
+
|
111
|
+
if trigger_defs.any?
|
112
|
+
queries << <<-SQL
|
113
|
+
CREATE OR REPLACE FUNCTION #{quote_ident(trigger_name)}()
|
114
|
+
RETURNS trigger AS $$
|
115
|
+
BEGIN
|
116
|
+
IF #{trigger_defs.join("\n ELSIF ")}
|
117
|
+
ELSE
|
118
|
+
RAISE EXCEPTION 'Date out of range. Ensure partitions are created.';
|
119
|
+
END IF;
|
120
|
+
RETURN NULL;
|
121
|
+
END;
|
122
|
+
$$ LANGUAGE plpgsql;
|
123
|
+
SQL
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
run_queries(queries) if queries.any?
|
128
|
+
end
|
129
|
+
end
|
130
|
+
end
|
@@ -0,0 +1,13 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "analyze TABLE", "Analyze tables"
|
4
|
+
option :swapped, type: :boolean, default: false, desc: "Use swapped table"
|
5
|
+
def analyze(table)
|
6
|
+
table = create_table(table)
|
7
|
+
parent_table = options[:swapped] ? table : table.intermediate_table
|
8
|
+
|
9
|
+
analyze_list = parent_table.partitions + [parent_table]
|
10
|
+
run_queries_without_transaction(analyze_list.map { |t| "ANALYZE VERBOSE #{quote_table(t)};" })
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
@@ -0,0 +1,103 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "fill TABLE", "Fill the partitions in batches"
|
4
|
+
option :batch_size, type: :numeric, default: 10000, desc: "Batch size"
|
5
|
+
option :swapped, type: :boolean, default: false, desc: "Use swapped table"
|
6
|
+
option :source_table, desc: "Source table"
|
7
|
+
option :dest_table, desc: "Destination table"
|
8
|
+
option :start, type: :numeric, desc: "Primary key to start"
|
9
|
+
option :where, desc: "Conditions to filter"
|
10
|
+
option :sleep, type: :numeric, desc: "Seconds to sleep between batches"
|
11
|
+
def fill(table)
|
12
|
+
table = create_table(table)
|
13
|
+
source_table = create_table(options[:source_table]) if options[:source_table]
|
14
|
+
dest_table = create_table(options[:dest_table]) if options[:dest_table]
|
15
|
+
|
16
|
+
if options[:swapped]
|
17
|
+
source_table ||= table.retired_table
|
18
|
+
dest_table ||= table
|
19
|
+
else
|
20
|
+
source_table ||= table
|
21
|
+
dest_table ||= table.intermediate_table
|
22
|
+
end
|
23
|
+
|
24
|
+
assert_table(source_table)
|
25
|
+
assert_table(dest_table)
|
26
|
+
|
27
|
+
period, field, cast, _, declarative, _ = dest_table.fetch_settings(table.trigger_name)
|
28
|
+
|
29
|
+
if period
|
30
|
+
name_format = self.name_format(period)
|
31
|
+
|
32
|
+
partitions = dest_table.partitions
|
33
|
+
if partitions.any?
|
34
|
+
starting_time = partition_date(partitions.first, name_format)
|
35
|
+
ending_time = advance_date(partition_date(partitions.last, name_format), period, 1)
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
schema_table = period && declarative ? partitions.last : table
|
40
|
+
|
41
|
+
primary_key = schema_table.primary_key[0]
|
42
|
+
abort "No primary key" unless primary_key
|
43
|
+
|
44
|
+
max_source_id = nil
|
45
|
+
begin
|
46
|
+
max_source_id = source_table.max_id(primary_key)
|
47
|
+
rescue PG::UndefinedFunction
|
48
|
+
abort "Only numeric primary keys are supported"
|
49
|
+
end
|
50
|
+
|
51
|
+
max_dest_id =
|
52
|
+
if options[:start]
|
53
|
+
options[:start]
|
54
|
+
elsif options[:swapped]
|
55
|
+
dest_table.max_id(primary_key, where: options[:where], below: max_source_id)
|
56
|
+
else
|
57
|
+
dest_table.max_id(primary_key, where: options[:where])
|
58
|
+
end
|
59
|
+
|
60
|
+
if max_dest_id == 0 && !options[:swapped]
|
61
|
+
min_source_id = source_table.min_id(primary_key, field, cast, starting_time, options[:where])
|
62
|
+
max_dest_id = min_source_id - 1 if min_source_id
|
63
|
+
end
|
64
|
+
|
65
|
+
starting_id = max_dest_id
|
66
|
+
fields = source_table.columns.map { |c| quote_ident(c) }.join(", ")
|
67
|
+
batch_size = options[:batch_size]
|
68
|
+
|
69
|
+
i = 1
|
70
|
+
batch_count = ((max_source_id - starting_id) / batch_size.to_f).ceil
|
71
|
+
|
72
|
+
if batch_count == 0
|
73
|
+
log_sql "/* nothing to fill */"
|
74
|
+
end
|
75
|
+
|
76
|
+
while starting_id < max_source_id
|
77
|
+
where = "#{quote_ident(primary_key)} > #{starting_id} AND #{quote_ident(primary_key)} <= #{starting_id + batch_size}"
|
78
|
+
if starting_time
|
79
|
+
where << " AND #{quote_ident(field)} >= #{sql_date(starting_time, cast)} AND #{quote_ident(field)} < #{sql_date(ending_time, cast)}"
|
80
|
+
end
|
81
|
+
if options[:where]
|
82
|
+
where << " AND #{options[:where]}"
|
83
|
+
end
|
84
|
+
|
85
|
+
query = <<-SQL
|
86
|
+
/* #{i} of #{batch_count} */
|
87
|
+
INSERT INTO #{quote_table(dest_table)} (#{fields})
|
88
|
+
SELECT #{fields} FROM #{quote_table(source_table)}
|
89
|
+
WHERE #{where}
|
90
|
+
SQL
|
91
|
+
|
92
|
+
run_query(query)
|
93
|
+
|
94
|
+
starting_id += batch_size
|
95
|
+
i += 1
|
96
|
+
|
97
|
+
if options[:sleep] && starting_id <= max_source_id
|
98
|
+
sleep(options[:sleep])
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
102
|
+
end
|
103
|
+
end
|
@@ -0,0 +1,97 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "prep TABLE [COLUMN] [PERIOD]", "Create an intermediate table for partitioning"
|
4
|
+
option :partition, type: :boolean, default: true, desc: "Partition the table"
|
5
|
+
option :trigger_based, type: :boolean, default: false, desc: "Use trigger-based partitioning"
|
6
|
+
def prep(table, column=nil, period=nil)
|
7
|
+
table = create_table(table)
|
8
|
+
intermediate_table = table.intermediate_table
|
9
|
+
trigger_name = table.trigger_name
|
10
|
+
|
11
|
+
unless options[:partition]
|
12
|
+
abort "Usage: \"pgslice prep TABLE --no-partition\"" if column || period
|
13
|
+
abort "Can't use --trigger-based and --no-partition" if options[:trigger_based]
|
14
|
+
end
|
15
|
+
assert_table(table)
|
16
|
+
assert_no_table(intermediate_table)
|
17
|
+
|
18
|
+
if options[:partition]
|
19
|
+
abort "Usage: \"pgslice prep TABLE COLUMN PERIOD\"" if !(column && period)
|
20
|
+
abort "Column not found: #{column}" unless table.columns.include?(column)
|
21
|
+
abort "Invalid period: #{period}" unless SQL_FORMAT[period.to_sym]
|
22
|
+
end
|
23
|
+
|
24
|
+
queries = []
|
25
|
+
|
26
|
+
# version summary
|
27
|
+
# 1. trigger-based
|
28
|
+
# 2. declarative, with indexes and foreign keys on child tables
|
29
|
+
# 3. declarative, with indexes and foreign keys on parent table
|
30
|
+
version =
|
31
|
+
if options[:trigger_based] || server_version_num < 100000
|
32
|
+
1
|
33
|
+
elsif server_version_num < 110000
|
34
|
+
2
|
35
|
+
else
|
36
|
+
3
|
37
|
+
end
|
38
|
+
|
39
|
+
declarative = version > 1
|
40
|
+
|
41
|
+
if declarative && options[:partition]
|
42
|
+
queries << <<-SQL
|
43
|
+
CREATE TABLE #{quote_table(intermediate_table)} (LIKE #{quote_table(table)} INCLUDING DEFAULTS INCLUDING CONSTRAINTS INCLUDING STORAGE INCLUDING COMMENTS) PARTITION BY RANGE (#{quote_ident(column)});
|
44
|
+
SQL
|
45
|
+
|
46
|
+
if version == 3
|
47
|
+
index_defs = table.index_defs
|
48
|
+
index_defs.each do |index_def|
|
49
|
+
queries << make_index_def(index_def, intermediate_table)
|
50
|
+
end
|
51
|
+
|
52
|
+
table.foreign_keys.each do |fk_def|
|
53
|
+
queries << make_fk_def(fk_def, intermediate_table)
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
# add comment
|
58
|
+
cast = table.column_cast(column)
|
59
|
+
queries << <<-SQL
|
60
|
+
COMMENT ON TABLE #{quote_table(intermediate_table)} is 'column:#{column},period:#{period},cast:#{cast},version:#{version}';
|
61
|
+
SQL
|
62
|
+
else
|
63
|
+
queries << <<-SQL
|
64
|
+
CREATE TABLE #{quote_table(intermediate_table)} (LIKE #{quote_table(table)} INCLUDING ALL);
|
65
|
+
SQL
|
66
|
+
|
67
|
+
table.foreign_keys.each do |fk_def|
|
68
|
+
queries << make_fk_def(fk_def, intermediate_table)
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
if options[:partition] && !declarative
|
73
|
+
queries << <<-SQL
|
74
|
+
CREATE FUNCTION #{quote_ident(trigger_name)}()
|
75
|
+
RETURNS trigger AS $$
|
76
|
+
BEGIN
|
77
|
+
RAISE EXCEPTION 'Create partitions first.';
|
78
|
+
END;
|
79
|
+
$$ LANGUAGE plpgsql;
|
80
|
+
SQL
|
81
|
+
|
82
|
+
queries << <<-SQL
|
83
|
+
CREATE TRIGGER #{quote_ident(trigger_name)}
|
84
|
+
BEFORE INSERT ON #{quote_table(intermediate_table)}
|
85
|
+
FOR EACH ROW EXECUTE PROCEDURE #{quote_ident(trigger_name)}();
|
86
|
+
SQL
|
87
|
+
|
88
|
+
cast = table.column_cast(column)
|
89
|
+
queries << <<-SQL
|
90
|
+
COMMENT ON TRIGGER #{quote_ident(trigger_name)} ON #{quote_table(intermediate_table)} is 'column:#{column},period:#{period},cast:#{cast}';
|
91
|
+
SQL
|
92
|
+
end
|
93
|
+
|
94
|
+
run_queries(queries)
|
95
|
+
end
|
96
|
+
end
|
97
|
+
end
|
@@ -0,0 +1,28 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "swap TABLE", "Swap the intermediate table with the original table"
|
4
|
+
option :lock_timeout, default: "5s", desc: "Lock timeout"
|
5
|
+
def swap(table)
|
6
|
+
table = create_table(table)
|
7
|
+
intermediate_table = table.intermediate_table
|
8
|
+
retired_table = table.retired_table
|
9
|
+
|
10
|
+
assert_table(table)
|
11
|
+
assert_table(intermediate_table)
|
12
|
+
assert_no_table(retired_table)
|
13
|
+
|
14
|
+
queries = [
|
15
|
+
"ALTER TABLE #{quote_table(table)} RENAME TO #{quote_no_schema(retired_table)};",
|
16
|
+
"ALTER TABLE #{quote_table(intermediate_table)} RENAME TO #{quote_no_schema(table)};"
|
17
|
+
]
|
18
|
+
|
19
|
+
table.sequences.each do |sequence|
|
20
|
+
queries << "ALTER SEQUENCE #{quote_ident(sequence["sequence_name"])} OWNED BY #{quote_table(table)}.#{quote_ident(sequence["related_column"])};"
|
21
|
+
end
|
22
|
+
|
23
|
+
queries.unshift("SET LOCAL lock_timeout = '#{options[:lock_timeout]}';") if server_version_num >= 90300
|
24
|
+
|
25
|
+
run_queries(queries)
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
@@ -0,0 +1,18 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "unprep TABLE", "Undo prep"
|
4
|
+
def unprep(table)
|
5
|
+
table = create_table(table)
|
6
|
+
intermediate_table = table.intermediate_table
|
7
|
+
trigger_name = table.trigger_name
|
8
|
+
|
9
|
+
assert_table(intermediate_table)
|
10
|
+
|
11
|
+
queries = [
|
12
|
+
"DROP TABLE #{quote_table(intermediate_table)} CASCADE;",
|
13
|
+
"DROP FUNCTION IF EXISTS #{quote_ident(trigger_name)}();"
|
14
|
+
]
|
15
|
+
run_queries(queries)
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
@@ -0,0 +1,25 @@
|
|
1
|
+
module PgSlice
|
2
|
+
class CLI
|
3
|
+
desc "unswap TABLE", "Undo swap"
|
4
|
+
def unswap(table)
|
5
|
+
table = create_table(table)
|
6
|
+
intermediate_table = table.intermediate_table
|
7
|
+
retired_table = table.retired_table
|
8
|
+
|
9
|
+
assert_table(table)
|
10
|
+
assert_table(retired_table)
|
11
|
+
assert_no_table(intermediate_table)
|
12
|
+
|
13
|
+
queries = [
|
14
|
+
"ALTER TABLE #{quote_table(table)} RENAME TO #{quote_no_schema(intermediate_table)};",
|
15
|
+
"ALTER TABLE #{quote_table(retired_table)} RENAME TO #{quote_no_schema(table)};"
|
16
|
+
]
|
17
|
+
|
18
|
+
table.sequences.each do |sequence|
|
19
|
+
queries << "ALTER SEQUENCE #{quote_ident(sequence["sequence_name"])} OWNED BY #{quote_table(table)}.#{quote_ident(sequence["related_column"])};"
|
20
|
+
end
|
21
|
+
|
22
|
+
run_queries(queries)
|
23
|
+
end
|
24
|
+
end
|
25
|
+
end
|