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,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,3 @@
1
+ module PartitionGardener
2
+ class MissingConflictIndex < StandardError; end
3
+ end
@@ -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,3 @@
1
+ module PartitionGardener
2
+ class OrphanedRebalanceStaging < StandardError; end
3
+ 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