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,59 @@
1
+ module PartitionGardener
2
+ class GapDetection
3
+ Gap = Data.define(:range_start, :range_end, :message)
4
+
5
+ def self.call(table_name, config: Registry.find_by_table_name(table_name))
6
+ new(table_name, config: config).call
7
+ end
8
+
9
+ def initialize(table_name, config: nil)
10
+ @table_name = table_name
11
+ @config = config || Registry.find_by_table_name(table_name)
12
+ end
13
+
14
+ def call
15
+ return [] unless @config
16
+
17
+ strategy = Strategy.for(@config)
18
+ return [] unless range_layout?(strategy)
19
+
20
+ segments = strategy.attached_tail_segments
21
+ return [] if segments.empty?
22
+
23
+ sorted = segments.sort_by { |segment| [segment.range_start.to_s, segment.name] }
24
+ gaps = []
25
+
26
+ sorted.each_cons(2) do |left, right|
27
+ left_end = normalize_range_end(left.range_end)
28
+ right_start = right.range_start
29
+ next if left_end == right_start
30
+
31
+ gaps << Gap.new(
32
+ range_start: left_end,
33
+ range_end: right_start,
34
+ message: "uncovered range between #{left.name} and #{right.name} (#{left_end}..#{right_start})"
35
+ )
36
+ end
37
+
38
+ unless sorted.any? { |segment| segment.range_end == :max }
39
+ gaps << Gap.new(
40
+ range_start: sorted.last.range_end,
41
+ range_end: :max,
42
+ message: "no attached tail partition extends to MAXVALUE"
43
+ )
44
+ end
45
+
46
+ gaps
47
+ end
48
+
49
+ private
50
+
51
+ def range_layout?(strategy)
52
+ strategy.is_a?(Strategy::DateRange) || strategy.is_a?(Strategy::IntegerRange)
53
+ end
54
+
55
+ def normalize_range_end(range_end)
56
+ (range_end == :max) ? nil : range_end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,70 @@
1
+ module PartitionGardener
2
+ module HashRouting
3
+ module_function
4
+
5
+ def collect_bucket_counts(config)
6
+ table_name = config[:table_name]
7
+ modulus = config.fetch(:hash_modulus, Strategy::HashBranches::DEFAULT_MODULUS)
8
+ bucket_counts = Hash.new(0)
9
+
10
+ Connection.attached_partitions(table_name).each do |partition|
11
+ next if partition.default
12
+
13
+ remainder = remainder_from_partition(partition, table_name)
14
+ next if remainder.nil?
15
+
16
+ bucket_counts[remainder] += Connection.count_rows_in_partition_table(partition.name)
17
+ end
18
+
19
+ default_name = Naming.default_partition_name(table_name)
20
+ if Connection.partition_exists?(default_name) && Connection.partition_attached?(table_name, default_name)
21
+ counts_in_default(default_name, config[:partition_key_column], modulus).each do |remainder, count|
22
+ bucket_counts[remainder] += count
23
+ end
24
+ end
25
+
26
+ bucket_counts
27
+ end
28
+
29
+ def remainder_from_partition(partition, table_name)
30
+ if partition.range_start.is_a?(Hash) && partition.range_start.key?(:remainder)
31
+ return partition.range_start[:remainder]
32
+ end
33
+
34
+ match = partition.name.match(/^#{Regexp.escape(table_name)}_[ha]_(\d+)$/)
35
+ return match[1].to_i if match
36
+
37
+ nil
38
+ end
39
+
40
+ def counts_in_default(partition_name, partition_key_column, modulus)
41
+ connection = Connection.connection
42
+ column = connection.quote_column_name(partition_key_column)
43
+ remainder_sql = remainder_sql_expression(column, modulus, connection)
44
+
45
+ sql = <<~SQL
46
+ SELECT #{remainder_sql} AS remainder,
47
+ COUNT(*)::int AS row_count
48
+ FROM #{Connection.quoted_table(partition_name)}
49
+ GROUP BY 1
50
+ SQL
51
+
52
+ connection.execute(sql).each_with_object({}) do |row, counts|
53
+ counts[row["remainder"].to_i] = row["row_count"].to_i
54
+ end
55
+ end
56
+
57
+ def remainder_sql_expression(quoted_column, modulus, connection)
58
+ if integer_column?(quoted_column)
59
+ "mod(abs(hashint8extended(#{quoted_column}::bigint, 0)), #{modulus})"
60
+ else
61
+ "mod(abs(hashtextextended(#{quoted_column}::text, 0)), #{modulus})"
62
+ end
63
+ end
64
+
65
+ def integer_column?(quoted_column)
66
+ normalized = quoted_column.delete('"').downcase
67
+ normalized == "id" || normalized.end_with?("_id")
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,28 @@
1
+ module PartitionGardener
2
+ module Layout
3
+ class CalendarYear
4
+ DEFAULT_ACTIVE_YEARS = Strategy::DateRange::DEFAULT_ACTIVE_YEARS
5
+
6
+ class << self
7
+ def active_end(active_start:, active_years: DEFAULT_ACTIVE_YEARS)
8
+ DateCalendar.add_years(active_start, active_years)
9
+ end
10
+
11
+ def build_segments(config:, active_start:, active_end:, hot_years:)
12
+ strategy = Strategy::DateRange.new(config.merge(bucket: :year))
13
+ buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
14
+
15
+ ZoneSegments.build_filler_and_hot_segments(
16
+ table_name: config[:table_name],
17
+ buckets: buckets,
18
+ hot_buckets: hot_years,
19
+ active_start: active_start,
20
+ active_end: active_end,
21
+ hot_bucket_name: config[:partition_name_format],
22
+ bucket_end: ->(bucket) { DateCalendar.beginning_of_year(DateCalendar.next_year(bucket)) }
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ module PartitionGardener
2
+ module Layout
3
+ class IntegerWindow
4
+ DEFAULT_ACTIVE_ID_WIDTH = Strategy::IntegerRange::DEFAULT_ACTIVE_ID_WIDTH
5
+
6
+ class << self
7
+ def active_end(active_start:, active_id_width: DEFAULT_ACTIVE_ID_WIDTH)
8
+ active_start + active_id_width
9
+ end
10
+
11
+ def build_segments(config:, active_start:, active_end:, hot_buckets:)
12
+ strategy = Strategy::IntegerRange.new(config)
13
+ buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
14
+
15
+ ZoneSegments.build_filler_and_hot_segments(
16
+ table_name: config[:table_name],
17
+ buckets: buckets,
18
+ hot_buckets: hot_buckets,
19
+ active_start: active_start,
20
+ active_end: active_end,
21
+ hot_bucket_name: strategy.method(:hot_bucket_partition_name),
22
+ bucket_end: ->(bucket) { [bucket + strategy.send(:current_band_size), active_end].min }
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,28 @@
1
+ module PartitionGardener
2
+ module Layout
3
+ class SlidingWindow
4
+ class << self
5
+ def active_end(active_start:, bucket: :month, active_span: nil)
6
+ span = active_span || DateBucket.default_active_span(bucket)
7
+ DateBucket.add_buckets(active_start, span, bucket)
8
+ end
9
+
10
+ def build_segments(config:, active_start:, active_end:, hot_months:)
11
+ strategy = Strategy::DateRange.new(config)
12
+ buckets = strategy.send(:each_bucket_in_range, active_start, active_end)
13
+ bucket = config.fetch(:bucket, :month)
14
+
15
+ ZoneSegments.build_filler_and_hot_segments(
16
+ table_name: config[:table_name],
17
+ buckets: buckets,
18
+ hot_buckets: hot_months,
19
+ active_start: active_start,
20
+ active_end: active_end,
21
+ hot_bucket_name: config[:partition_name_format],
22
+ bucket_end: ->(bucket_start) { DateBucket.end_of_bucket(bucket_start, bucket) }
23
+ )
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,27 @@
1
+ module PartitionGardener
2
+ module Layout
3
+ # Every partitioning strategy targets the same three zones on the key axis:
4
+ #
5
+ # archive — coldest data; fine-grained children, retention-friendly, rarely written
6
+ # current — hot active span; most reads/writes; may contain heat splits inside the zone
7
+ # future — beyond the active window; sparse, less hot than current, not yet archived
8
+ #
9
+ # Strategy plugins (date range, integer range, hash) only define how keys map to bounds.
10
+ # Heatmap collection and hot-bucket policy live on each strategy plugin.
11
+ # Executor cursor moves are identical: ORDER BY partition_key, conflict_key — strategy-agnostic.
12
+ module ThreeArea
13
+ ZONES = %i[archive current future].freeze
14
+
15
+ class << self
16
+ def zone_slot_name(table_name, zone)
17
+ case zone
18
+ when :current then Naming.current_partition_name(table_name)
19
+ when :future then Naming.future_partition_name(table_name)
20
+ else
21
+ raise ArgumentError, "archive zone uses strategy-specific bucket names, not a single slot"
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,62 @@
1
+ module PartitionGardener
2
+ module Layout
3
+ module ZoneSegments
4
+ module_function
5
+
6
+ def build_filler_and_hot_segments(table_name:, buckets:, hot_buckets:, active_start:, active_end:, hot_bucket_name:, bucket_end:)
7
+ hot_bucket_set = hot_buckets.to_set
8
+ segments = []
9
+ middle_filler_index = 0
10
+ index = 0
11
+
12
+ while index < buckets.length
13
+ bucket = buckets[index]
14
+
15
+ if hot_bucket_set.include?(bucket)
16
+ segments << Plan::Segment.new(
17
+ name: hot_bucket_name.call(bucket),
18
+ range_start: bucket,
19
+ range_end: bucket_end.call(bucket),
20
+ kind: :hot_bucket
21
+ )
22
+ index += 1
23
+ next
24
+ end
25
+
26
+ run_start = bucket
27
+ while index < buckets.length && !hot_bucket_set.include?(buckets[index])
28
+ index += 1
29
+ end
30
+ run_end = (index < buckets.length) ? buckets[index] : active_end
31
+
32
+ segment_name = if run_start == active_start
33
+ Naming.current_partition_name(table_name)
34
+ else
35
+ middle_filler_index += 1
36
+ if middle_filler_index == 1
37
+ Naming.open_partition_name(table_name)
38
+ else
39
+ "#{table_name}_open_#{middle_filler_index}"
40
+ end
41
+ end
42
+
43
+ segments << Plan::Segment.new(
44
+ name: segment_name,
45
+ range_start: run_start,
46
+ range_end: run_end,
47
+ kind: :filler
48
+ )
49
+ end
50
+
51
+ segments << Plan::Segment.new(
52
+ name: Naming.future_partition_name(table_name),
53
+ range_start: active_end,
54
+ range_end: :max,
55
+ kind: :future
56
+ )
57
+
58
+ segments
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,3 @@
1
+ module PartitionGardener
2
+ class LockNotAcquired < StandardError; end
3
+ end
@@ -0,0 +1,73 @@
1
+ module PartitionGardener
2
+ module MaintenanceBackend
3
+ GARDENER = :gardener
4
+ PG_PARTMAN = :pg_partman
5
+ HYBRID_LAYOUT_ONLY = :hybrid_layout_only
6
+
7
+ class ValidationError < ArgumentError; end
8
+
9
+ module_function
10
+
11
+ def normalize(backend)
12
+ backend&.to_sym || GARDENER
13
+ end
14
+
15
+ def gardener_owned?(config)
16
+ normalize(config[:maintenance_backend]) != PG_PARTMAN
17
+ end
18
+
19
+ def skipped?(config)
20
+ normalize(config[:maintenance_backend]) == PG_PARTMAN
21
+ end
22
+
23
+ def hybrid?(config)
24
+ normalize(config[:maintenance_backend]) == HYBRID_LAYOUT_ONLY
25
+ end
26
+
27
+ def partman_parent_configured?(table_name)
28
+ Connection.partman_parent_configured?(table_name)
29
+ rescue
30
+ false
31
+ end
32
+
33
+ def validate!(config)
34
+ violations = validation_messages(config)
35
+ return if violations.empty?
36
+
37
+ violations.each do |message|
38
+ if PartitionGardener.configuration.strict_maintenance_backend_validation
39
+ raise ValidationError, message
40
+ end
41
+
42
+ PartitionGardener.configuration.notify(
43
+ "[PartitionGardener] #{message}",
44
+ context: {
45
+ table_name: config[:table_name],
46
+ maintenance_backend: normalize(config[:maintenance_backend])
47
+ }
48
+ )
49
+ end
50
+ end
51
+
52
+ def validation_messages(config)
53
+ backend = normalize(config[:maintenance_backend])
54
+ table_name = config[:table_name]
55
+ partman_row = partman_parent_configured?(table_name)
56
+ messages = []
57
+
58
+ if backend == PG_PARTMAN && !partman_row
59
+ messages << "#{table_name} maintenance_backend is pg_partman but partman.part_config has no row"
60
+ end
61
+
62
+ if backend == GARDENER && partman_row
63
+ messages << "#{table_name} is registered for gardener but partman.part_config also lists this parent; pick one maintainer"
64
+ end
65
+
66
+ if hybrid?(config) && !partman_row
67
+ messages << "#{table_name} hybrid_layout_only expects partman premake on the same parent"
68
+ end
69
+
70
+ messages
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,19 @@
1
+ module PartitionGardener
2
+ class MemoryRunRecordStore
3
+ def initialize
4
+ @records = {}
5
+ end
6
+
7
+ def load(table_name)
8
+ @records[table_name]
9
+ end
10
+
11
+ def save(table_name, attributes)
12
+ @records[table_name] = attributes
13
+ end
14
+
15
+ def clear(table_name)
16
+ @records.delete(table_name)
17
+ end
18
+ end
19
+ end