partition_gardener 0.3.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.
Files changed (82) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +36 -0
  3. data/LICENSE.md +21 -0
  4. data/README.md +203 -0
  5. data/SECURITY.md +27 -0
  6. data/docs/THIRD_PARTY_LICENSE_MANIFEST.tsv +60 -0
  7. data/docs/application_contract.md +82 -0
  8. data/docs/audit_reference.md +125 -0
  9. data/docs/background_job.md +161 -0
  10. data/docs/cli.md +71 -0
  11. data/docs/configuration.md +261 -0
  12. data/docs/cutover.md +180 -0
  13. data/docs/decision_flow.md +154 -0
  14. data/docs/host_testing.md +91 -0
  15. data/docs/monitoring.md +110 -0
  16. data/docs/naming.md +71 -0
  17. data/docs/operations.md +131 -0
  18. data/docs/partition_landscape.md +323 -0
  19. data/docs/pg_party_recipe.md +44 -0
  20. data/docs/related_postgres_tooling.md +195 -0
  21. data/docs/retention.md +65 -0
  22. data/docs/schemas/partition_garden.schema.json +114 -0
  23. data/docs/schemas/plan_report.schema.json +82 -0
  24. data/docs/tooling_split.md +79 -0
  25. data/exe/partition_gardener +6 -0
  26. data/lib/partition_gardener/active_record_run_record_store.rb +1 -0
  27. data/lib/partition_gardener/advisory_lock.rb +48 -0
  28. data/lib/partition_gardener/archive_retention.rb +88 -0
  29. data/lib/partition_gardener/audit.rb +93 -0
  30. data/lib/partition_gardener/blank.rb +13 -0
  31. data/lib/partition_gardener/cli.rb +190 -0
  32. data/lib/partition_gardener/config_document.rb +240 -0
  33. data/lib/partition_gardener/configuration.rb +102 -0
  34. data/lib/partition_gardener/connection.rb +260 -0
  35. data/lib/partition_gardener/date_bucket.rb +117 -0
  36. data/lib/partition_gardener/date_calendar.rb +65 -0
  37. data/lib/partition_gardener/date_range_maintenance.rb +297 -0
  38. data/lib/partition_gardener/default_partition.rb +54 -0
  39. data/lib/partition_gardener/executor.rb +324 -0
  40. data/lib/partition_gardener/gap_detection.rb +59 -0
  41. data/lib/partition_gardener/hash_routing.rb +70 -0
  42. data/lib/partition_gardener/layout/calendar_year.rb +28 -0
  43. data/lib/partition_gardener/layout/integer_window.rb +28 -0
  44. data/lib/partition_gardener/layout/sliding_window.rb +28 -0
  45. data/lib/partition_gardener/layout/three_area.rb +27 -0
  46. data/lib/partition_gardener/layout/zone_segments.rb +62 -0
  47. data/lib/partition_gardener/lock_not_acquired.rb +3 -0
  48. data/lib/partition_gardener/maintenance_backend.rb +73 -0
  49. data/lib/partition_gardener/memory_run_record_store.rb +19 -0
  50. data/lib/partition_gardener/migration/hot_switch_concern.rb +445 -0
  51. data/lib/partition_gardener/missing_conflict_index.rb +3 -0
  52. data/lib/partition_gardener/naming.rb +29 -0
  53. data/lib/partition_gardener/orphaned_rebalance_staging.rb +3 -0
  54. data/lib/partition_gardener/pg_connection.rb +94 -0
  55. data/lib/partition_gardener/plan.rb +49 -0
  56. data/lib/partition_gardener/plan_applier.rb +289 -0
  57. data/lib/partition_gardener/plan_diff.rb +51 -0
  58. data/lib/partition_gardener/plan_report.rb +95 -0
  59. data/lib/partition_gardener/planner.rb +21 -0
  60. data/lib/partition_gardener/predicate.rb +85 -0
  61. data/lib/partition_gardener/premake_monthly_maintenance.rb +44 -0
  62. data/lib/partition_gardener/rails.rb +12 -0
  63. data/lib/partition_gardener/registry.rb +84 -0
  64. data/lib/partition_gardener/run_failed.rb +10 -0
  65. data/lib/partition_gardener/run_metrics.rb +65 -0
  66. data/lib/partition_gardener/run_record.rb +76 -0
  67. data/lib/partition_gardener/sql_run_record_store.rb +106 -0
  68. data/lib/partition_gardener/stdlib_extensions.rb +15 -0
  69. data/lib/partition_gardener/strategy/composite.rb +27 -0
  70. data/lib/partition_gardener/strategy/cursor_columns.rb +18 -0
  71. data/lib/partition_gardener/strategy/date_range.rb +303 -0
  72. data/lib/partition_gardener/strategy/hash_branches.rb +161 -0
  73. data/lib/partition_gardener/strategy/integer_range.rb +261 -0
  74. data/lib/partition_gardener/strategy/list_split.rb +125 -0
  75. data/lib/partition_gardener/strategy/requires_default_partition.rb +19 -0
  76. data/lib/partition_gardener/strategy.rb +26 -0
  77. data/lib/partition_gardener/templates.rb +373 -0
  78. data/lib/partition_gardener/unmoved_rows_remaining.rb +15 -0
  79. data/lib/partition_gardener/version.rb +3 -0
  80. data/lib/partition_gardener.rb +215 -0
  81. data/sig/partition_gardener.rbs +19 -0
  82. metadata +367 -0
@@ -0,0 +1,260 @@
1
+ module PartitionGardener
2
+ module Connection
3
+ module_function
4
+
5
+ def connection
6
+ PartitionGardener.configuration.connection
7
+ end
8
+
9
+ def schema_name
10
+ PartitionGardener.configuration.schema_name
11
+ end
12
+
13
+ def quoted_table(name)
14
+ connection.quote_table_name(name)
15
+ end
16
+
17
+ def table_is_partitioned?(table_name)
18
+ return false unless connection.table_exists?(table_name)
19
+
20
+ sql = <<~SQL
21
+ SELECT COUNT(*) AS count
22
+ FROM pg_partitioned_table pt
23
+ JOIN pg_class c ON c.oid = pt.partrelid
24
+ JOIN pg_namespace n ON n.oid = c.relnamespace
25
+ WHERE n.nspname = #{connection.quote(schema_name)}
26
+ AND c.relname = #{connection.quote(table_name)}
27
+ SQL
28
+ connection.execute(sql).first["count"].to_i.positive?
29
+ end
30
+
31
+ def partition_exists?(partition_name)
32
+ connection.table_exists?(partition_name)
33
+ end
34
+
35
+ def partition_attached?(table_name, partition_name)
36
+ sql = <<~SQL
37
+ SELECT COUNT(*) AS count
38
+ FROM pg_catalog.pg_inherits i
39
+ JOIN pg_class parent ON parent.oid = i.inhparent
40
+ JOIN pg_namespace parent_ns ON parent_ns.oid = parent.relnamespace
41
+ JOIN pg_class child ON child.oid = i.inhrelid
42
+ JOIN pg_namespace child_ns ON child_ns.oid = child.relnamespace
43
+ WHERE parent_ns.nspname = #{connection.quote(schema_name)}
44
+ AND child_ns.nspname = #{connection.quote(schema_name)}
45
+ AND parent.relname = #{connection.quote(table_name)}
46
+ AND child.relname = #{connection.quote(partition_name)}
47
+ SQL
48
+
49
+ connection.execute(sql).first["count"].to_i.positive?
50
+ end
51
+
52
+ def current_partition_lower_bound(table_name, partition_name)
53
+ sql = <<~SQL
54
+ SELECT pg_get_expr(child.relpartbound, child.oid) AS bound_expression
55
+ FROM pg_catalog.pg_inherits inheritance
56
+ JOIN pg_class parent ON parent.oid = inheritance.inhparent
57
+ JOIN pg_namespace parent_namespace ON parent_namespace.oid = parent.relnamespace
58
+ JOIN pg_class child ON child.oid = inheritance.inhrelid
59
+ JOIN pg_namespace child_namespace ON child_namespace.oid = child.relnamespace
60
+ WHERE parent_namespace.nspname = #{connection.quote(schema_name)}
61
+ AND child_namespace.nspname = #{connection.quote(schema_name)}
62
+ AND parent.relname = #{connection.quote(table_name)}
63
+ AND child.relname = #{connection.quote(partition_name)}
64
+ SQL
65
+
66
+ bound_expression = connection.execute(sql).first&.fetch("bound_expression", nil)
67
+ return if Blank.blank?(bound_expression)
68
+
69
+ match = bound_expression.match(/FROM \('([^']+)'\)/)
70
+ match ? Date.parse(match[1]) : nil
71
+ end
72
+
73
+ def count_rows_in_partition_table(partition_name)
74
+ sql = <<~SQL
75
+ SELECT COUNT(*) AS count
76
+ FROM #{quoted_table(partition_name)}
77
+ SQL
78
+ connection.execute(sql).first["count"].to_i
79
+ end
80
+
81
+ def count_rows_in_partition(partition_name, where_condition)
82
+ sql = <<~SQL
83
+ SELECT COUNT(*) AS count
84
+ FROM #{quoted_table(partition_name)}
85
+ WHERE #{where_condition}
86
+ SQL
87
+ connection.execute(sql).first["count"].to_i
88
+ end
89
+
90
+ def attached_partitions(table_name)
91
+ attached_partitions_cache[table_name] ||= fetch_attached_partitions(table_name)
92
+ end
93
+
94
+ def clear_attached_partitions_cache!
95
+ Thread.current[:partition_gardener_attached_partitions] = nil
96
+ end
97
+
98
+ def fetch_attached_partitions(table_name)
99
+ sql = <<~SQL
100
+ SELECT child.relname AS partition_name,
101
+ pg_get_expr(child.relpartbound, child.oid) AS bound_expression
102
+ FROM pg_catalog.pg_inherits inheritance
103
+ JOIN pg_class parent ON parent.oid = inheritance.inhparent
104
+ JOIN pg_namespace parent_namespace ON parent_namespace.oid = parent.relnamespace
105
+ JOIN pg_class child ON child.oid = inheritance.inhrelid
106
+ JOIN pg_namespace child_namespace ON child_namespace.oid = child.relnamespace
107
+ WHERE parent_namespace.nspname = #{connection.quote(schema_name)}
108
+ AND child_namespace.nspname = #{connection.quote(schema_name)}
109
+ AND parent.relname = #{connection.quote(table_name)}
110
+ ORDER BY child.relname
111
+ SQL
112
+
113
+ connection.execute(sql).filter_map do |row|
114
+ parse_attached_partition(row["partition_name"], row["bound_expression"])
115
+ end
116
+ end
117
+
118
+ def attached_partitions_cache
119
+ Thread.current[:partition_gardener_attached_partitions] ||= {}
120
+ end
121
+
122
+ private :attached_partitions_cache, :fetch_attached_partitions
123
+
124
+ def parse_attached_partition(partition_name, bound_expression)
125
+ return if Blank.blank?(bound_expression)
126
+
127
+ if bound_expression == "DEFAULT"
128
+ return AttachedPartition.new(
129
+ name: partition_name,
130
+ range_start: nil,
131
+ range_end: nil,
132
+ default: true,
133
+ list_values: nil
134
+ )
135
+ end
136
+
137
+ hash_match = bound_expression.match(/modulus (\d+), remainder (\d+)/)
138
+ if hash_match
139
+ return AttachedPartition.new(
140
+ name: partition_name,
141
+ range_start: {modulus: hash_match[1].to_i, remainder: hash_match[2].to_i},
142
+ range_end: nil,
143
+ default: false,
144
+ list_values: nil
145
+ )
146
+ end
147
+
148
+ list_match = bound_expression.match(/^IN \((.+)\)$/)
149
+ if list_match
150
+ values = list_match[1].split(",").map { |value| parse_list_value(value.strip) }
151
+ return AttachedPartition.new(
152
+ name: partition_name,
153
+ range_start: values.first,
154
+ range_end: nil,
155
+ default: false,
156
+ list_values: values
157
+ )
158
+ end
159
+
160
+ from_match = bound_expression.match(/FROM \('([^']+)'\)/) || bound_expression.match(/FROM \((\d+)\)/)
161
+ to_match = bound_expression.match(/TO \('([^']+)'\)/) || bound_expression.match(/TO \((\d+)\)/)
162
+ range_end = if bound_expression.include?("MAXVALUE")
163
+ :max
164
+ elsif to_match
165
+ parse_bound_value(to_match[1])
166
+ end
167
+
168
+ AttachedPartition.new(
169
+ name: partition_name,
170
+ range_start: from_match ? parse_bound_value(from_match[1]) : nil,
171
+ range_end: range_end,
172
+ default: false,
173
+ list_values: nil
174
+ )
175
+ end
176
+
177
+ def parse_list_value(value)
178
+ return nil if value.upcase == "NULL"
179
+
180
+ value.delete_prefix("'").delete_suffix("'")
181
+ end
182
+
183
+ def parse_bound_value(value)
184
+ return Date.parse(value) if value.include?("-")
185
+
186
+ value.to_i
187
+ end
188
+
189
+ AttachedPartition = Data.define(:name, :range_start, :range_end, :default, :list_values) do
190
+ def signature
191
+ [name, range_start, range_end, default, list_values]
192
+ end
193
+ end
194
+
195
+ def get_distinct_partition_identifiers(partition_name, partition_key_column, extract_partition_identifier)
196
+ if partition_key_column.include?("::")
197
+ base_column = partition_key_column.split("::").first.strip
198
+ sql = <<~SQL
199
+ SELECT DISTINCT #{partition_key_column} AS partition_key_value
200
+ FROM #{quoted_table(partition_name)}
201
+ WHERE #{connection.quote_column_name(base_column)} IS NOT NULL
202
+ ORDER BY #{partition_key_column}
203
+ SQL
204
+ else
205
+ sql = <<~SQL
206
+ SELECT DISTINCT #{connection.quote_column_name(partition_key_column)} AS partition_key_value
207
+ FROM #{quoted_table(partition_name)}
208
+ WHERE #{connection.quote_column_name(partition_key_column)} IS NOT NULL
209
+ ORDER BY #{connection.quote_column_name(partition_key_column)}
210
+ SQL
211
+ end
212
+
213
+ connection.execute(sql).to_a.filter_map do |row|
214
+ value = row["partition_key_value"]
215
+ extract_partition_identifier.call(value) if Blank.present?(value)
216
+ end.uniq
217
+ end
218
+
219
+ def unique_index_column_sets(table_name)
220
+ sql = <<~SQL
221
+ SELECT i.indisprimary AS is_primary,
222
+ array_agg(a.attname ORDER BY key_ord.ordinality) AS column_names
223
+ FROM pg_index i
224
+ JOIN pg_class table_class ON table_class.oid = i.indrelid
225
+ JOIN pg_namespace table_namespace ON table_namespace.oid = table_class.relnamespace
226
+ JOIN LATERAL unnest(i.indkey) WITH ORDINALITY AS key_ord(attnum, ordinality) ON true
227
+ JOIN pg_attribute a ON a.attrelid = table_class.oid AND a.attnum = key_ord.attnum
228
+ WHERE table_namespace.nspname = #{connection.quote(schema_name)}
229
+ AND table_class.relname = #{connection.quote(table_name)}
230
+ AND (i.indisunique OR i.indisprimary)
231
+ GROUP BY i.indexrelid, i.indisprimary
232
+ SQL
233
+
234
+ connection.execute(sql).map do |row|
235
+ columns = row["column_names"]
236
+ columns = columns.gsub(/[{}]/, "").split(",") if columns.is_a?(String)
237
+ columns
238
+ end
239
+ end
240
+
241
+ def unique_index_covers?(table_name, conflict_key)
242
+ conflict_columns = conflict_key.map(&:to_s)
243
+ unique_index_column_sets(table_name).any? do |index_columns|
244
+ index_columns.first(conflict_columns.length) == conflict_columns
245
+ end
246
+ end
247
+
248
+ def partman_parent_configured?(table_name)
249
+ qualified_name = "#{schema_name}.#{table_name}"
250
+ sql = <<~SQL
251
+ SELECT 1
252
+ FROM partman.part_config
253
+ WHERE parent_table = #{connection.quote(qualified_name)}
254
+ LIMIT 1
255
+ SQL
256
+
257
+ connection.execute(sql).any?
258
+ end
259
+ end
260
+ end
@@ -0,0 +1,117 @@
1
+ module PartitionGardener
2
+ module DateBucket
3
+ module_function
4
+
5
+ def normalize(bucket)
6
+ bucket.to_sym
7
+ end
8
+
9
+ def active_key(bucket)
10
+ case normalize(bucket)
11
+ when :day then :active_days
12
+ when :week then :active_weeks
13
+ when :quarter then :active_quarters
14
+ when :year then :active_years
15
+ else :active_months
16
+ end
17
+ end
18
+
19
+ def default_active_span(bucket)
20
+ case normalize(bucket)
21
+ when :day then 90
22
+ when :week then 52
23
+ when :quarter then 8
24
+ when :year then 2
25
+ else 12
26
+ end
27
+ end
28
+
29
+ def date_trunc_unit(bucket)
30
+ case normalize(bucket)
31
+ when :day then "day"
32
+ when :week then "week"
33
+ when :quarter then "quarter"
34
+ when :year then "year"
35
+ else "month"
36
+ end
37
+ end
38
+
39
+ def beginning_of_bucket(date, bucket)
40
+ case normalize(bucket)
41
+ when :day then DateCalendar.beginning_of_day(date)
42
+ when :week then DateCalendar.beginning_of_week(date)
43
+ when :quarter then DateCalendar.beginning_of_quarter(date)
44
+ when :year then DateCalendar.beginning_of_year(date)
45
+ else DateCalendar.beginning_of_month(date)
46
+ end
47
+ end
48
+
49
+ def end_of_bucket(date, bucket)
50
+ beginning_of_bucket(next_bucket(date, bucket), bucket)
51
+ end
52
+
53
+ def next_bucket(date, bucket)
54
+ case normalize(bucket)
55
+ when :day then DateCalendar.add_days(date, 1)
56
+ when :week then DateCalendar.add_weeks(date, 1)
57
+ when :quarter then DateCalendar.add_quarters(date, 1)
58
+ when :year then DateCalendar.next_year(date)
59
+ else DateCalendar.next_month(date)
60
+ end
61
+ end
62
+
63
+ def add_buckets(date, count, bucket)
64
+ case normalize(bucket)
65
+ when :day then DateCalendar.add_days(date, count)
66
+ when :week then DateCalendar.add_weeks(date, count)
67
+ when :quarter then DateCalendar.add_quarters(date, count)
68
+ when :year then DateCalendar.add_years(date, count)
69
+ else DateCalendar.add_months(date, count)
70
+ end
71
+ end
72
+
73
+ def partition_name_suffix(identifier, bucket)
74
+ case normalize(bucket)
75
+ when :day then identifier.strftime("%Y_%m_%d")
76
+ when :week then identifier.strftime("%G_W%V")
77
+ when :quarter
78
+ quarter = ((identifier.month - 1) / 3) + 1
79
+ format("%d_Q%d", identifier.year, quarter)
80
+ when :year then identifier.year.to_s
81
+ else identifier.strftime("%Y_%m")
82
+ end
83
+ end
84
+
85
+ def partition_name(table_name, identifier, bucket)
86
+ "#{table_name}_#{partition_name_suffix(identifier, bucket)}"
87
+ end
88
+
89
+ def archive_bucket_from_partition_name(table_name, partition_name, bucket)
90
+ case normalize(bucket)
91
+ when :day
92
+ match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})_(\d{2})_(\d{2})$/)
93
+ return Date.new(match[1].to_i, match[2].to_i, match[3].to_i) if match
94
+ when :week
95
+ match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})_W(\d{2})$/)
96
+ return DateCalendar.beginning_of_week(Date.strptime("#{match[1]}-W#{match[2]}-1", "%G-W%V-%u")) if match
97
+ when :quarter
98
+ match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})_Q([1-4])$/)
99
+ return Date.new(match[1].to_i, ((match[2].to_i - 1) * 3) + 1, 1) if match
100
+ when :year
101
+ match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})$/)
102
+ return Date.new(match[1].to_i, 1, 1) if match
103
+ else
104
+ match = partition_name.match(/^#{Regexp.escape(table_name)}_(\d{4})_(\d{2})$/)
105
+ return Date.new(match[1].to_i, match[2].to_i, 1) if match
106
+ end
107
+
108
+ nil
109
+ end
110
+
111
+ def partition_definition_clause(date, bucket)
112
+ start_range = beginning_of_bucket(date, bucket)
113
+ end_range = end_of_bucket(date, bucket)
114
+ "FROM ('#{start_range}') TO ('#{end_range}')"
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,65 @@
1
+ module PartitionGardener
2
+ module DateCalendar
3
+ module_function
4
+
5
+ def to_date(value)
6
+ value.respond_to?(:to_date) ? value.to_date : Date.parse(value.to_s)
7
+ end
8
+
9
+ def beginning_of_month(date)
10
+ value = to_date(date)
11
+ Date.new(value.year, value.month, 1)
12
+ end
13
+
14
+ def beginning_of_year(date)
15
+ value = to_date(date)
16
+ Date.new(value.year, 1, 1)
17
+ end
18
+
19
+ def beginning_of_day(date)
20
+ to_date(date)
21
+ end
22
+
23
+ def beginning_of_week(date)
24
+ value = to_date(date)
25
+ value - ((value.wday + 6) % 7)
26
+ end
27
+
28
+ def beginning_of_quarter(date)
29
+ value = to_date(date)
30
+ quarter_month = ((value.month - 1) / 3) * 3 + 1
31
+ Date.new(value.year, quarter_month, 1)
32
+ end
33
+
34
+ def add_days(date, count)
35
+ to_date(date) + count
36
+ end
37
+
38
+ def add_weeks(date, count)
39
+ add_days(date, count * 7)
40
+ end
41
+
42
+ def add_quarters(date, count)
43
+ add_months(date, count * 3)
44
+ end
45
+
46
+ def next_month(date)
47
+ add_months(beginning_of_month(date), 1)
48
+ end
49
+
50
+ def next_year(date)
51
+ Date.new(to_date(date).year + 1, 1, 1)
52
+ end
53
+
54
+ def add_years(date, count)
55
+ value = to_date(date)
56
+ Date.new(value.year + count, value.month, value.day)
57
+ end
58
+
59
+ def add_months(date, count)
60
+ base = beginning_of_month(date)
61
+ month_index = (base.year * 12) + (base.month - 1) + count
62
+ Date.new(month_index / 12, (month_index % 12) + 1, 1)
63
+ end
64
+ end
65
+ end