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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.md +21 -0
- data/README.md +203 -0
- data/SECURITY.md +27 -0
- data/docs/THIRD_PARTY_LICENSE_MANIFEST.tsv +60 -0
- data/docs/application_contract.md +82 -0
- data/docs/audit_reference.md +125 -0
- data/docs/background_job.md +161 -0
- data/docs/cli.md +71 -0
- data/docs/configuration.md +261 -0
- data/docs/cutover.md +180 -0
- data/docs/decision_flow.md +154 -0
- data/docs/host_testing.md +91 -0
- data/docs/monitoring.md +110 -0
- data/docs/naming.md +71 -0
- data/docs/operations.md +131 -0
- data/docs/partition_landscape.md +323 -0
- data/docs/pg_party_recipe.md +44 -0
- data/docs/related_postgres_tooling.md +195 -0
- data/docs/retention.md +65 -0
- data/docs/schemas/partition_garden.schema.json +114 -0
- data/docs/schemas/plan_report.schema.json +82 -0
- data/docs/tooling_split.md +79 -0
- data/exe/partition_gardener +6 -0
- data/lib/partition_gardener/active_record_run_record_store.rb +1 -0
- data/lib/partition_gardener/advisory_lock.rb +48 -0
- data/lib/partition_gardener/archive_retention.rb +88 -0
- data/lib/partition_gardener/audit.rb +93 -0
- data/lib/partition_gardener/blank.rb +13 -0
- data/lib/partition_gardener/cli.rb +190 -0
- data/lib/partition_gardener/config_document.rb +240 -0
- data/lib/partition_gardener/configuration.rb +102 -0
- data/lib/partition_gardener/connection.rb +260 -0
- data/lib/partition_gardener/date_bucket.rb +117 -0
- data/lib/partition_gardener/date_calendar.rb +65 -0
- data/lib/partition_gardener/date_range_maintenance.rb +297 -0
- data/lib/partition_gardener/default_partition.rb +54 -0
- data/lib/partition_gardener/executor.rb +324 -0
- data/lib/partition_gardener/gap_detection.rb +59 -0
- data/lib/partition_gardener/hash_routing.rb +70 -0
- data/lib/partition_gardener/layout/calendar_year.rb +28 -0
- data/lib/partition_gardener/layout/integer_window.rb +28 -0
- data/lib/partition_gardener/layout/sliding_window.rb +28 -0
- data/lib/partition_gardener/layout/three_area.rb +27 -0
- data/lib/partition_gardener/layout/zone_segments.rb +62 -0
- data/lib/partition_gardener/lock_not_acquired.rb +3 -0
- data/lib/partition_gardener/maintenance_backend.rb +73 -0
- data/lib/partition_gardener/memory_run_record_store.rb +19 -0
- data/lib/partition_gardener/migration/hot_switch_concern.rb +445 -0
- data/lib/partition_gardener/missing_conflict_index.rb +3 -0
- data/lib/partition_gardener/naming.rb +29 -0
- data/lib/partition_gardener/orphaned_rebalance_staging.rb +3 -0
- data/lib/partition_gardener/pg_connection.rb +94 -0
- data/lib/partition_gardener/plan.rb +49 -0
- data/lib/partition_gardener/plan_applier.rb +289 -0
- data/lib/partition_gardener/plan_diff.rb +51 -0
- data/lib/partition_gardener/plan_report.rb +95 -0
- data/lib/partition_gardener/planner.rb +21 -0
- data/lib/partition_gardener/predicate.rb +85 -0
- data/lib/partition_gardener/premake_monthly_maintenance.rb +44 -0
- data/lib/partition_gardener/rails.rb +12 -0
- data/lib/partition_gardener/registry.rb +84 -0
- data/lib/partition_gardener/run_failed.rb +10 -0
- data/lib/partition_gardener/run_metrics.rb +65 -0
- data/lib/partition_gardener/run_record.rb +76 -0
- data/lib/partition_gardener/sql_run_record_store.rb +106 -0
- data/lib/partition_gardener/stdlib_extensions.rb +15 -0
- data/lib/partition_gardener/strategy/composite.rb +27 -0
- data/lib/partition_gardener/strategy/cursor_columns.rb +18 -0
- data/lib/partition_gardener/strategy/date_range.rb +303 -0
- data/lib/partition_gardener/strategy/hash_branches.rb +161 -0
- data/lib/partition_gardener/strategy/integer_range.rb +261 -0
- data/lib/partition_gardener/strategy/list_split.rb +125 -0
- data/lib/partition_gardener/strategy/requires_default_partition.rb +19 -0
- data/lib/partition_gardener/strategy.rb +26 -0
- data/lib/partition_gardener/templates.rb +373 -0
- data/lib/partition_gardener/unmoved_rows_remaining.rb +15 -0
- data/lib/partition_gardener/version.rb +3 -0
- data/lib/partition_gardener.rb +215 -0
- data/sig/partition_gardener.rbs +19 -0
- metadata +367 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Migration
|
|
3
|
+
# Hot-switch from a live non-partitioned table to a declarative partitioned shadow table.
|
|
4
|
+
#
|
|
5
|
+
# Recommended at switch time:
|
|
6
|
+
# - create default + minimal premake (months_ahead: 1)
|
|
7
|
+
# - run nightly PartitionGardener maintenance after cutover for sliding-window layout
|
|
8
|
+
#
|
|
9
|
+
# +partition_config+ may be an inline hash or resolved from Registry via
|
|
10
|
+
# +PartitionGardener::Registry.hot_switch_partition_config+.
|
|
11
|
+
module HotSwitchConcern
|
|
12
|
+
DEFAULT_SWAP_LOCK_TIMEOUT = "5s"
|
|
13
|
+
|
|
14
|
+
def self.included(base)
|
|
15
|
+
base.extend(ClassMethods)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
module ClassMethods
|
|
19
|
+
def hot_switch_partition_config(table_name)
|
|
20
|
+
PartitionGardener::Registry.hot_switch_partition_config(table_name)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def ensure_future_partitions_exist(months_ahead: 1)
|
|
25
|
+
config = hot_switch_config
|
|
26
|
+
partition_config = resolve_partition_config(config)
|
|
27
|
+
return unless partition_config
|
|
28
|
+
|
|
29
|
+
if months_ahead > 3
|
|
30
|
+
say "Warning: months_ahead=#{months_ahead} creates many partitions at switch time; prefer 1 and let PartitionGardener maintain the sliding window"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
today = PartitionGardener.configuration.today
|
|
34
|
+
partitions_to_create = partition_config[:partitions_to_create].call(today)
|
|
35
|
+
additional_months = (1..months_ahead).map { |month_offset| DateCalendar.add_months(today, month_offset) }
|
|
36
|
+
all_months = (partitions_to_create + additional_months).uniq.sort
|
|
37
|
+
|
|
38
|
+
partition_name_format = partition_config[:partition_name_format]
|
|
39
|
+
partition_definition = partition_config[:partition_definition]
|
|
40
|
+
partitioned_table = config[:partitioned_table]
|
|
41
|
+
current_table = config[:current_table]
|
|
42
|
+
|
|
43
|
+
all_months.each do |identifier|
|
|
44
|
+
partition_name = partition_name_format.call(identifier).gsub(/^#{current_table}_/, "#{partitioned_table}_")
|
|
45
|
+
next if connection.table_exists?(partition_name)
|
|
46
|
+
|
|
47
|
+
for_values_clause = partition_definition.call(identifier)
|
|
48
|
+
sql = <<~SQL
|
|
49
|
+
CREATE TABLE IF NOT EXISTS #{quoted_table(partition_name)} PARTITION OF #{quoted_table(partitioned_table)}
|
|
50
|
+
FOR VALUES #{for_values_clause}
|
|
51
|
+
SQL
|
|
52
|
+
execute(sql)
|
|
53
|
+
say "Created partition #{partition_name}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
say "Ensured #{all_months.size} partitions exist (including #{months_ahead} months ahead)"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def analyze_shadow_partitions!
|
|
60
|
+
config = hot_switch_config
|
|
61
|
+
partitioned_table = config[:partitioned_table]
|
|
62
|
+
|
|
63
|
+
say "Analyzing shadow partitions for #{partitioned_table}"
|
|
64
|
+
|
|
65
|
+
fetch_partitions(partitioned_table).each do |partition|
|
|
66
|
+
execute "ANALYZE #{partition}"
|
|
67
|
+
say "Analyzed #{partition}"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
execute "ANALYZE #{quoted_table(partitioned_table)}"
|
|
71
|
+
say "Analyzed #{partitioned_table}"
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def add_write_block_trigger(table_name)
|
|
75
|
+
trigger_name = "#{table_name}_write_block_trigger"
|
|
76
|
+
function_name = "#{table_name}_write_block_function"
|
|
77
|
+
|
|
78
|
+
sql = <<~SQL
|
|
79
|
+
CREATE OR REPLACE FUNCTION #{function_name}()
|
|
80
|
+
RETURNS TRIGGER AS $$
|
|
81
|
+
BEGIN
|
|
82
|
+
RAISE EXCEPTION 'Table % is read-only. Write operations are blocked during hot switch.', '#{table_name}';
|
|
83
|
+
END;
|
|
84
|
+
$$ LANGUAGE plpgsql;
|
|
85
|
+
SQL
|
|
86
|
+
execute(sql)
|
|
87
|
+
|
|
88
|
+
execute "DROP TRIGGER IF EXISTS #{trigger_name} ON #{quoted_table(table_name)}"
|
|
89
|
+
sql = <<~SQL
|
|
90
|
+
CREATE TRIGGER #{trigger_name}
|
|
91
|
+
BEFORE INSERT OR UPDATE OR DELETE ON #{quoted_table(table_name)}
|
|
92
|
+
FOR EACH ROW
|
|
93
|
+
EXECUTE FUNCTION #{function_name}();
|
|
94
|
+
SQL
|
|
95
|
+
execute(sql)
|
|
96
|
+
|
|
97
|
+
say "Added write-block trigger to #{table_name}"
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def remove_write_block_trigger(table_name, initial_table_name)
|
|
101
|
+
trigger_name = "#{initial_table_name}_write_block_trigger"
|
|
102
|
+
function_name = "#{initial_table_name}_write_block_function"
|
|
103
|
+
|
|
104
|
+
execute "DROP TRIGGER IF EXISTS #{trigger_name} ON #{quoted_table(table_name)}"
|
|
105
|
+
execute "DROP FUNCTION IF EXISTS #{function_name}()"
|
|
106
|
+
say "Removed write-block trigger from #{table_name}"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def wait_for_active_transactions(table_name, timeout_seconds: 300, check_interval_seconds: 1)
|
|
110
|
+
start_time = Time.current
|
|
111
|
+
say "Waiting for active transactions on #{table_name} to complete..."
|
|
112
|
+
|
|
113
|
+
loop do
|
|
114
|
+
active_transactions_sql = <<~SQL
|
|
115
|
+
SELECT COUNT(*) AS count
|
|
116
|
+
FROM pg_stat_activity
|
|
117
|
+
WHERE state IN ('active', 'idle in transaction')
|
|
118
|
+
AND pid != pg_backend_pid()
|
|
119
|
+
AND query NOT LIKE '%pg_stat_activity%'
|
|
120
|
+
AND query NOT LIKE '%information_schema%'
|
|
121
|
+
AND (
|
|
122
|
+
query ILIKE '%#{table_name}%'
|
|
123
|
+
OR query ILIKE '%LOCK TABLE #{table_name}%'
|
|
124
|
+
)
|
|
125
|
+
SQL
|
|
126
|
+
|
|
127
|
+
active_count = execute(active_transactions_sql).first["count"].to_i
|
|
128
|
+
if active_count.zero?
|
|
129
|
+
say "No active transactions on #{table_name}"
|
|
130
|
+
return true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
elapsed = Time.current - start_time
|
|
134
|
+
if elapsed > timeout_seconds
|
|
135
|
+
raise "Timeout waiting for active transactions on #{table_name}. #{active_count} transactions still active."
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
say "Waiting... #{active_count} active transactions on #{table_name} (elapsed: #{elapsed.round}s)"
|
|
139
|
+
sleep(check_interval_seconds)
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def sync_delta_data(batch_size: nil, source_table: nil, target_table: nil, swapped: false, sleep_seconds: 0)
|
|
144
|
+
config = hot_switch_config
|
|
145
|
+
batch_size = batch_size || config[:sync_batch_size] || PartitionGardener::MOVE_BATCH_SIZE
|
|
146
|
+
source_table, target_table = resolve_sync_tables(source_table: source_table, target_table: target_table, swapped: swapped)
|
|
147
|
+
partition_key_column = config[:partition_key_column]
|
|
148
|
+
conflict_key = config[:conflict_key] || default_conflict_key(partition_key_column)
|
|
149
|
+
derived_columns = config[:derived_columns] || {}
|
|
150
|
+
stale_column = config[:sync_stale_column] || "updated_at"
|
|
151
|
+
base_key = partition_key_column.to_s.split("::").first.strip
|
|
152
|
+
current_partition_key_expression = config[:current_partition_key_expression] || "s.#{connection.quote_column_name(base_key)}"
|
|
153
|
+
today = PartitionGardener.configuration.today
|
|
154
|
+
start_date = DateCalendar.add_months(today, -3)
|
|
155
|
+
end_date = DateCalendar.add_months(today, 3)
|
|
156
|
+
start_date_sql = connection.quote(start_date)
|
|
157
|
+
end_date_sql = connection.quote(end_date)
|
|
158
|
+
|
|
159
|
+
say "Syncing delta data from #{source_table} to #{target_table}"
|
|
160
|
+
|
|
161
|
+
current_columns = table_columns(source_table)
|
|
162
|
+
partitioned_columns = table_columns(target_table)
|
|
163
|
+
common_columns = current_columns & partitioned_columns
|
|
164
|
+
insert_columns = (common_columns + derived_columns.keys) & partitioned_columns
|
|
165
|
+
columns_str = insert_columns.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
166
|
+
inner_select_columns_str = insert_columns.map do |column|
|
|
167
|
+
if derived_columns.key?(column)
|
|
168
|
+
"#{derived_columns[column]} AS #{connection.quote_column_name(column)}"
|
|
169
|
+
else
|
|
170
|
+
"s.#{connection.quote_column_name(column)}"
|
|
171
|
+
end
|
|
172
|
+
end.join(", ")
|
|
173
|
+
|
|
174
|
+
conflict_key_str = conflict_key.map { |column| connection.quote_column_name(column) }.join(", ")
|
|
175
|
+
conflict_match = conflict_key.map { |column| "d.#{connection.quote_column_name(column)} = s.#{connection.quote_column_name(column)}" }.join(" AND ")
|
|
176
|
+
update_columns = insert_columns - conflict_key
|
|
177
|
+
update_clause = update_columns.map { |column| "#{connection.quote_column_name(column)} = EXCLUDED.#{connection.quote_column_name(column)}" }.join(", ")
|
|
178
|
+
stale_check = if partitioned_columns.include?(stale_column)
|
|
179
|
+
"d.#{connection.quote_column_name(stale_column)} >= s.#{connection.quote_column_name(stale_column)}"
|
|
180
|
+
else
|
|
181
|
+
"FALSE"
|
|
182
|
+
end
|
|
183
|
+
window_predicate = [
|
|
184
|
+
"#{current_partition_key_expression} >= #{start_date_sql}",
|
|
185
|
+
"AND #{current_partition_key_expression} < #{end_date_sql}",
|
|
186
|
+
"AND NOT EXISTS (",
|
|
187
|
+
"SELECT 1 FROM #{quoted_table(target_table)} d",
|
|
188
|
+
"WHERE #{conflict_match}",
|
|
189
|
+
"AND #{stale_check}",
|
|
190
|
+
")"
|
|
191
|
+
].join(" ")
|
|
192
|
+
order_clause = conflict_key.map { |column| "s.#{connection.quote_column_name(column)}" }.join(", ")
|
|
193
|
+
|
|
194
|
+
count_sql = <<~SQL
|
|
195
|
+
SELECT COUNT(*) AS count
|
|
196
|
+
FROM #{quoted_table(source_table)} s
|
|
197
|
+
WHERE #{window_predicate}
|
|
198
|
+
SQL
|
|
199
|
+
|
|
200
|
+
records_to_sync = execute(count_sql).first["count"].to_i
|
|
201
|
+
if records_to_sync.zero?
|
|
202
|
+
say "No records to sync - tables are already in sync"
|
|
203
|
+
return
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
say "Found #{records_to_sync} records to sync"
|
|
207
|
+
|
|
208
|
+
conflict_update_sql = if update_columns.empty?
|
|
209
|
+
<<~SQL.chomp
|
|
210
|
+
ON CONFLICT (#{conflict_key_str}) DO NOTHING
|
|
211
|
+
SQL
|
|
212
|
+
elsif partitioned_columns.include?(stale_column)
|
|
213
|
+
<<~SQL.chomp
|
|
214
|
+
ON CONFLICT (#{conflict_key_str}) DO UPDATE SET
|
|
215
|
+
#{update_clause}
|
|
216
|
+
WHERE #{quoted_table(target_table)}.#{connection.quote_column_name(stale_column)} < EXCLUDED.#{connection.quote_column_name(stale_column)}
|
|
217
|
+
SQL
|
|
218
|
+
else
|
|
219
|
+
<<~SQL.chomp
|
|
220
|
+
ON CONFLICT (#{conflict_key_str}) DO UPDATE SET
|
|
221
|
+
#{update_clause}
|
|
222
|
+
SQL
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
synced_total = 0
|
|
226
|
+
loop do
|
|
227
|
+
sync_sql = <<~SQL
|
|
228
|
+
INSERT INTO #{quoted_table(target_table)} (#{columns_str})
|
|
229
|
+
SELECT #{columns_str}
|
|
230
|
+
FROM (
|
|
231
|
+
SELECT #{inner_select_columns_str}
|
|
232
|
+
FROM #{quoted_table(source_table)} s
|
|
233
|
+
WHERE #{window_predicate}
|
|
234
|
+
ORDER BY #{order_clause}
|
|
235
|
+
LIMIT #{batch_size.to_i}
|
|
236
|
+
) batch
|
|
237
|
+
#{conflict_update_sql}
|
|
238
|
+
SQL
|
|
239
|
+
|
|
240
|
+
result = execute(sync_sql)
|
|
241
|
+
synced_batch = result.cmd_tuples
|
|
242
|
+
break if synced_batch.zero?
|
|
243
|
+
|
|
244
|
+
synced_total += synced_batch
|
|
245
|
+
sleep(sleep_seconds) if sleep_seconds.positive?
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
say "Synced #{synced_total} records"
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def table_columns(table_name)
|
|
252
|
+
sql = <<~SQL
|
|
253
|
+
SELECT column_name
|
|
254
|
+
FROM information_schema.columns
|
|
255
|
+
WHERE table_schema = #{connection.quote(PartitionGardener.configuration.schema_name)}
|
|
256
|
+
AND table_name = #{connection.quote(table_name)}
|
|
257
|
+
ORDER BY ordinal_position
|
|
258
|
+
SQL
|
|
259
|
+
|
|
260
|
+
execute(sql).map { |row| row["column_name"] }
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
alias_method :get_table_columns, :table_columns
|
|
264
|
+
|
|
265
|
+
def compare_table_counts
|
|
266
|
+
config = hot_switch_config
|
|
267
|
+
current_table = config[:current_table]
|
|
268
|
+
partitioned_table = config[:partitioned_table]
|
|
269
|
+
|
|
270
|
+
current_count = execute("SELECT COUNT(*) AS count FROM #{quoted_table(current_table)}").first["count"].to_i
|
|
271
|
+
partitioned_count = execute("SELECT COUNT(*) AS count FROM #{quoted_table(partitioned_table)}").first["count"].to_i
|
|
272
|
+
|
|
273
|
+
say "Count comparison:"
|
|
274
|
+
say " #{current_table}: #{current_count}"
|
|
275
|
+
say " #{partitioned_table}: #{partitioned_count}"
|
|
276
|
+
say " Difference: #{current_count - partitioned_count}"
|
|
277
|
+
|
|
278
|
+
{
|
|
279
|
+
current: current_count,
|
|
280
|
+
partitioned: partitioned_count,
|
|
281
|
+
difference: current_count - partitioned_count
|
|
282
|
+
}
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def fetch_partitions(table_name)
|
|
286
|
+
sql = <<~SQL
|
|
287
|
+
SELECT inhrelid::regclass AS child
|
|
288
|
+
FROM pg_catalog.pg_inherits
|
|
289
|
+
WHERE inhparent = #{connection.quote(table_name)}::regclass
|
|
290
|
+
SQL
|
|
291
|
+
execute(sql).map { |row| row["child"] }
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
def hot_switch_tables
|
|
295
|
+
config = hot_switch_config
|
|
296
|
+
current_table = config[:current_table]
|
|
297
|
+
partitioned_table = config[:partitioned_table]
|
|
298
|
+
old_table = "#{current_table}_old"
|
|
299
|
+
|
|
300
|
+
return if connection.table_exists?(old_table)
|
|
301
|
+
|
|
302
|
+
say "Performing hot switch: #{current_table} -> #{partitioned_table}"
|
|
303
|
+
|
|
304
|
+
sequence_pairs = serial_sequence_pairs(current_table)
|
|
305
|
+
|
|
306
|
+
transaction do
|
|
307
|
+
apply_swap_lock_timeout!
|
|
308
|
+
|
|
309
|
+
execute "ALTER TABLE #{quoted_table(current_table)} RENAME TO #{quoted_table(old_table)}"
|
|
310
|
+
say "Renamed #{current_table} to #{old_table}"
|
|
311
|
+
|
|
312
|
+
execute "ALTER TABLE #{quoted_table(partitioned_table)} RENAME TO #{quoted_table(current_table)}"
|
|
313
|
+
say "Renamed #{partitioned_table} to #{current_table}"
|
|
314
|
+
|
|
315
|
+
rename_partition_children!(current_table, partitioned_table, current_table)
|
|
316
|
+
repoint_serial_sequences!(current_table, sequence_pairs)
|
|
317
|
+
remove_write_block_trigger(old_table, current_table)
|
|
318
|
+
end
|
|
319
|
+
|
|
320
|
+
say "Hot switch completed successfully"
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def hot_unswitch_tables
|
|
324
|
+
config = hot_switch_config
|
|
325
|
+
current_table = config[:current_table]
|
|
326
|
+
partitioned_table = config[:partitioned_table]
|
|
327
|
+
old_table = "#{current_table}_old"
|
|
328
|
+
|
|
329
|
+
return unless connection.table_exists?(old_table)
|
|
330
|
+
return if connection.table_exists?(partitioned_table)
|
|
331
|
+
|
|
332
|
+
say "Performing hot unswitch: #{current_table} -> #{partitioned_table}"
|
|
333
|
+
|
|
334
|
+
sequence_pairs = serial_sequence_pairs(current_table)
|
|
335
|
+
|
|
336
|
+
transaction do
|
|
337
|
+
apply_swap_lock_timeout!
|
|
338
|
+
|
|
339
|
+
execute "ALTER TABLE #{quoted_table(current_table)} RENAME TO #{quoted_table(partitioned_table)}"
|
|
340
|
+
say "Renamed #{current_table} to #{partitioned_table}"
|
|
341
|
+
|
|
342
|
+
execute "ALTER TABLE #{quoted_table(old_table)} RENAME TO #{quoted_table(current_table)}"
|
|
343
|
+
say "Renamed #{old_table} to #{current_table}"
|
|
344
|
+
|
|
345
|
+
rename_partition_children!(partitioned_table, current_table, partitioned_table)
|
|
346
|
+
repoint_serial_sequences!(current_table, sequence_pairs)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
say "Hot unswitch completed successfully"
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
def serial_sequence_pairs(table_name)
|
|
353
|
+
qualified_table = qualified_table_name(table_name)
|
|
354
|
+
|
|
355
|
+
table_columns(table_name).filter_map do |column_name|
|
|
356
|
+
sql = <<~SQL
|
|
357
|
+
SELECT pg_get_serial_sequence(#{connection.quote(qualified_table)}, #{connection.quote(column_name)}) AS sequence_name
|
|
358
|
+
SQL
|
|
359
|
+
result = execute(sql).first
|
|
360
|
+
sequence_name = result["sequence_name"]
|
|
361
|
+
next if Blank.blank?(sequence_name)
|
|
362
|
+
|
|
363
|
+
[column_name, sequence_name]
|
|
364
|
+
end
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
private
|
|
368
|
+
|
|
369
|
+
def hot_switch_config
|
|
370
|
+
self.class::HOT_SWITCH_CONFIG
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def resolve_partition_config(config)
|
|
374
|
+
partition_config = config[:partition_config]
|
|
375
|
+
return partition_config unless partition_config.is_a?(String) || partition_config.is_a?(Symbol)
|
|
376
|
+
|
|
377
|
+
PartitionGardener::Registry.hot_switch_partition_config(partition_config.to_s)
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
def resolve_sync_tables(source_table:, target_table:, swapped:)
|
|
381
|
+
config = hot_switch_config
|
|
382
|
+
current_table = config[:current_table]
|
|
383
|
+
partitioned_table = config[:partitioned_table]
|
|
384
|
+
|
|
385
|
+
if swapped
|
|
386
|
+
[source_table || "#{current_table}_old", target_table || current_table]
|
|
387
|
+
else
|
|
388
|
+
[source_table || current_table, target_table || partitioned_table]
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def swap_lock_timeout_setting
|
|
393
|
+
config = hot_switch_config
|
|
394
|
+
return config[:swap_lock_timeout] if config.key?(:swap_lock_timeout)
|
|
395
|
+
|
|
396
|
+
DEFAULT_SWAP_LOCK_TIMEOUT
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
def apply_swap_lock_timeout!
|
|
400
|
+
timeout = swap_lock_timeout_setting
|
|
401
|
+
return if timeout.nil?
|
|
402
|
+
|
|
403
|
+
execute "SET LOCAL lock_timeout = #{connection.quote(timeout)}"
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
def rename_partition_children!(parent_table, from_prefix, to_prefix)
|
|
407
|
+
fetch_partitions(parent_table).each do |partition|
|
|
408
|
+
partition_name = partition.to_s
|
|
409
|
+
next if partition_name.include?(to_prefix)
|
|
410
|
+
|
|
411
|
+
new_partition_name = partition_name.gsub(from_prefix, to_prefix)
|
|
412
|
+
execute "ALTER TABLE #{partition} RENAME TO #{new_partition_name}"
|
|
413
|
+
say "Renamed #{partition} to #{new_partition_name}"
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
def repoint_serial_sequences!(table_name, sequence_pairs)
|
|
418
|
+
sequence_pairs.each do |column_name, sequence_name|
|
|
419
|
+
execute "ALTER SEQUENCE #{sequence_name} OWNED BY #{quoted_table(table_name)}.#{connection.quote_column_name(column_name)}"
|
|
420
|
+
say "Repointed #{sequence_name} to #{table_name}.#{column_name}"
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
def qualified_table_name(table_name)
|
|
425
|
+
"#{PartitionGardener.configuration.schema_name}.#{table_name}"
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def default_conflict_key(partition_key_column)
|
|
429
|
+
base_key = partition_key_column.to_s.split("::").first.strip
|
|
430
|
+
["id", base_key]
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def connection
|
|
434
|
+
PartitionGardener.configuration.connection
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def quoted_table(name)
|
|
438
|
+
connection.quote_table_name(name)
|
|
439
|
+
end
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
end
|
|
443
|
+
|
|
444
|
+
# Backward-compatible constant for migrations that include HotSwitchPartitionedTable.
|
|
445
|
+
HotSwitchPartitionedTable = PartitionGardener::Migration::HotSwitchConcern
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
# Slot names for Layout::ThreeArea zones.
|
|
3
|
+
# Archive buckets use strategy-specific names (YYYY_MM, id bands, hash remainder).
|
|
4
|
+
module Naming
|
|
5
|
+
module_function
|
|
6
|
+
|
|
7
|
+
def current_partition_name(table_name)
|
|
8
|
+
"#{table_name}_current"
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def open_partition_name(table_name)
|
|
12
|
+
"#{table_name}_open"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Current-zone gap filler (after heat splits). Not a fourth zone.
|
|
16
|
+
|
|
17
|
+
def future_partition_name(table_name)
|
|
18
|
+
"#{table_name}_future"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def rebalance_staging_partition_name(table_name)
|
|
22
|
+
"#{table_name}_rebalance_staging"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def default_partition_name(table_name)
|
|
26
|
+
"#{table_name}_default"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
require "pg"
|
|
2
|
+
|
|
3
|
+
module PartitionGardener
|
|
4
|
+
class PgConnection
|
|
5
|
+
class Result
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(pg_result)
|
|
9
|
+
@pg_result = pg_result
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def first
|
|
13
|
+
return nil if @pg_result.ntuples.zero?
|
|
14
|
+
|
|
15
|
+
@pg_result[0]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_a
|
|
19
|
+
@pg_result.to_a
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def cmd_tuples
|
|
23
|
+
@pg_result.cmd_tuples
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def each
|
|
27
|
+
return enum_for(:each) unless block_given?
|
|
28
|
+
|
|
29
|
+
@pg_result.each { |row| yield row }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.connect(database_url)
|
|
34
|
+
new(PG.connect(database_url))
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def initialize(raw_connection)
|
|
38
|
+
@raw_connection = raw_connection
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def quote(value)
|
|
42
|
+
case value
|
|
43
|
+
when nil
|
|
44
|
+
"NULL"
|
|
45
|
+
when true
|
|
46
|
+
"TRUE"
|
|
47
|
+
when false
|
|
48
|
+
"FALSE"
|
|
49
|
+
when Numeric
|
|
50
|
+
value.to_s
|
|
51
|
+
when Date, Time
|
|
52
|
+
"'#{@raw_connection.escape_string(value.iso8601)}'"
|
|
53
|
+
else
|
|
54
|
+
"'#{@raw_connection.escape_string(value.to_s)}'"
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def quote_table_name(name)
|
|
59
|
+
@raw_connection.quote_ident(name.to_s)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def quote_column_name(name)
|
|
63
|
+
quote_table_name(name)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def execute(sql)
|
|
67
|
+
Result.new(@raw_connection.exec(sql))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def transaction
|
|
71
|
+
@raw_connection.exec("BEGIN")
|
|
72
|
+
yield
|
|
73
|
+
@raw_connection.exec("COMMIT")
|
|
74
|
+
rescue
|
|
75
|
+
@raw_connection.exec("ROLLBACK")
|
|
76
|
+
raise
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def table_exists?(table_name)
|
|
80
|
+
sql = <<~SQL
|
|
81
|
+
SELECT EXISTS (
|
|
82
|
+
SELECT 1
|
|
83
|
+
FROM pg_catalog.pg_class AS relation
|
|
84
|
+
JOIN pg_catalog.pg_namespace AS namespace ON namespace.oid = relation.relnamespace
|
|
85
|
+
WHERE namespace.nspname = ANY (current_schemas(false))
|
|
86
|
+
AND relation.relname = $1
|
|
87
|
+
AND relation.relkind IN ('r', 'p')
|
|
88
|
+
) AS exists
|
|
89
|
+
SQL
|
|
90
|
+
result = @raw_connection.exec_params(sql, [table_name.to_s])
|
|
91
|
+
result.getvalue(0, 0) == "t"
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module PartitionGardener
|
|
2
|
+
module Plan
|
|
3
|
+
Segment = Data.define(:name, :range_start, :range_end, :kind) do
|
|
4
|
+
def monthly?
|
|
5
|
+
kind == :hot_bucket && !hash_partition?
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def hot_bucket?
|
|
9
|
+
kind == :hot_bucket
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def filler?
|
|
13
|
+
kind == :filler
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def future?
|
|
17
|
+
kind == :future
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def archive?
|
|
21
|
+
kind == :archive
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def hash_partition?
|
|
25
|
+
range_start.is_a?(Hash) && range_start.key?(:modulus)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def for_values_clause(strategy)
|
|
29
|
+
strategy.segment_for_values_clause(self)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def signature
|
|
33
|
+
[name, range_start, range_end, kind]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Result = Data.define(:segments, :hot_buckets) do
|
|
38
|
+
def hot_months
|
|
39
|
+
hot_buckets
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def changed?(attached_segments)
|
|
43
|
+
attached_signatures = attached_segments.map(&:signature).sort
|
|
44
|
+
target_signatures = segments.map(&:signature).sort
|
|
45
|
+
attached_signatures != target_signatures
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|