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,289 @@
1
+ module PartitionGardener
2
+ class PlanApplier
3
+ include Naming
4
+
5
+ def initialize(config, executor: nil, job_class_name: "PartitionGardener")
6
+ @config = config
7
+ @executor = executor || Executor.for_config(config)
8
+ @job_class_name = job_class_name
9
+ end
10
+
11
+ def apply!(plan)
12
+ DefaultPartition.ensure!(@config, executor: @executor)
13
+ attached = attached_tail_segments
14
+ return unless plan.changed?(attached)
15
+
16
+ table_name = @config[:table_name]
17
+ staging_name = rebalance_staging_partition_name(table_name)
18
+ plan_signature = PlanDiff.plan_signature(plan.segments)
19
+ PartitionGardener.configuration.current_run_metrics&.plan_signature = plan_signature
20
+ record = run_record_enabled? ? RunRecord.load(table_name) : nil
21
+
22
+ guard_against_orphaned_staging!(staging_name, record, plan_signature)
23
+ operations = PlanDiff.operations(attached, plan.segments)
24
+
25
+ if resumable_run?(record, plan_signature, staging_name)
26
+ notify_resume(record)
27
+ apply_resuming!(plan, operations, staging_name, record)
28
+ elsif incremental_rebalance?
29
+ apply_incremental!(plan, operations, staging_name, plan_signature)
30
+ else
31
+ apply_full!(plan, staging_name, plan_signature)
32
+ end
33
+
34
+ analyze_parent_table!(table_name)
35
+ notify_rebalance_complete(table_name, plan)
36
+ RunRecord.clear(table_name) if run_record_enabled?
37
+ end
38
+
39
+ private
40
+
41
+ def strategy
42
+ Strategy.for(@config)
43
+ end
44
+
45
+ def conflict_key
46
+ @config[:conflict_key] || begin
47
+ [@config[:partition_key_column].split("::").first.strip, "id"].uniq
48
+ end
49
+ end
50
+
51
+ def cursor_columns
52
+ strategy.cursor_columns
53
+ end
54
+
55
+ def connection
56
+ Connection.connection
57
+ end
58
+
59
+ def attached_tail_segments
60
+ Planner.new(@config).attached_tail_segments
61
+ end
62
+
63
+ def managed_tail_partition_names
64
+ strategy.managed_tail_partition_names
65
+ end
66
+
67
+ def incremental_rebalance?
68
+ @config.fetch(:incremental_rebalance, PartitionGardener.configuration.incremental_rebalance)
69
+ end
70
+
71
+ def run_record_enabled?
72
+ @config.fetch(:run_record_enabled, PartitionGardener.configuration.run_record_enabled)
73
+ end
74
+
75
+ def resumable_run?(record, plan_signature, staging_name)
76
+ return false unless run_record_enabled? && record&.incomplete?
77
+ return false unless record.plan_signature == plan_signature
78
+ return false unless Connection.partition_exists?(staging_name)
79
+
80
+ true
81
+ end
82
+
83
+ def guard_against_orphaned_staging!(staging_name, record, plan_signature)
84
+ return unless Connection.partition_exists?(staging_name)
85
+ return if resumable_run?(record, plan_signature, staging_name)
86
+
87
+ row_count = Connection.count_rows_in_partition_table(staging_name)
88
+ return if row_count.zero?
89
+
90
+ raise OrphanedRebalanceStaging,
91
+ "#{staging_name} holds #{row_count} row(s) from an interrupted rebalance; " \
92
+ "restore a matching run record or move rows manually before maintenance"
93
+ end
94
+
95
+ def apply_full!(plan, staging_name, plan_signature)
96
+ record = start_run_record(plan_signature) if run_record_enabled?
97
+
98
+ prepare_staging!(staging_name)
99
+ record&.advance!("detach")
100
+
101
+ detach_managed_tail_partitions!(staging_name)
102
+ drain_default_tail_rows_into_staging!(staging_name)
103
+ record&.advance!("detach", staging_row_count: staging_row_count(staging_name))
104
+
105
+ create_target_segments!(plan)
106
+ record&.advance!("segments")
107
+
108
+ move_staging_rows!(staging_name)
109
+ record&.advance!("rows", staging_row_count: 0)
110
+
111
+ @executor.drop_table(staging_name)
112
+ record&.advance!("cleanup")
113
+ end
114
+
115
+ def apply_incremental!(plan, operations, staging_name, plan_signature)
116
+ changed_operations = operations.reject { |operation| operation.action == :keep }
117
+ return if changed_operations.empty?
118
+
119
+ record = start_run_record(plan_signature) if run_record_enabled?
120
+ keep_names = operations.select { |operation| operation.action == :keep }.map { |operation| operation.segment.name }
121
+
122
+ prepare_staging!(staging_name)
123
+ record&.advance!("detach")
124
+
125
+ detach_managed_tail_partitions!(staging_name, skip_names: keep_names)
126
+ drain_default_tail_rows_into_staging!(staging_name)
127
+ record&.advance!("detach", staging_row_count: staging_row_count(staging_name))
128
+
129
+ segments_to_create = changed_operations.filter_map(&:segment).uniq(&:name)
130
+ create_segments!(segments_to_create)
131
+ record&.advance!("segments")
132
+
133
+ move_staging_rows!(staging_name)
134
+ record&.advance!("rows", staging_row_count: 0)
135
+
136
+ drop_removed_partitions!(operations)
137
+ @executor.drop_table(staging_name)
138
+ record&.advance!("cleanup")
139
+ end
140
+
141
+ def apply_resuming!(plan, operations, staging_name, record)
142
+ unless record.phase_at_least?("detach")
143
+ prepare_staging!(staging_name)
144
+ detach_managed_tail_partitions!(staging_name, skip_names: keep_names_from(operations))
145
+ drain_default_tail_rows_into_staging!(staging_name)
146
+ record = record.advance!("detach", staging_row_count: staging_row_count(staging_name))
147
+ end
148
+
149
+ unless record.phase_at_least?("segments")
150
+ create_target_segments!(plan)
151
+ record = record.advance!("segments")
152
+ end
153
+
154
+ unless record.phase_at_least?("rows")
155
+ move_staging_rows!(staging_name)
156
+ record = record.advance!("rows", staging_row_count: 0)
157
+ end
158
+
159
+ drop_removed_partitions!(operations) if incremental_rebalance?
160
+
161
+ unless record.phase_at_least?("cleanup")
162
+ @executor.drop_table(staging_name) if Connection.partition_exists?(staging_name)
163
+ record.advance!("cleanup")
164
+ end
165
+ end
166
+
167
+ def keep_names_from(operations)
168
+ operations.select { |operation| operation.action == :keep }.map { |operation| operation.segment.name }
169
+ end
170
+
171
+ def prepare_staging!(staging_name)
172
+ @executor.drop_table(staging_name)
173
+ @executor.ensure_detached_partition_table!(@config[:table_name], staging_name, conflict_key: conflict_key)
174
+ end
175
+
176
+ def create_target_segments!(plan)
177
+ create_segments!(plan.segments)
178
+ end
179
+
180
+ def create_segments!(segments)
181
+ return if MaintenanceBackend.hybrid?(@config)
182
+
183
+ table_name = @config[:table_name]
184
+
185
+ segments.each do |segment|
186
+ next if Connection.partition_attached?(table_name, segment.name)
187
+
188
+ @executor.create_partition(
189
+ table_name,
190
+ segment.name,
191
+ segment.for_values_clause(strategy)
192
+ )
193
+ end
194
+ end
195
+
196
+ def move_staging_rows!(staging_name)
197
+ table_name = @config[:table_name]
198
+ return unless Connection.partition_exists?(staging_name)
199
+ return if Connection.count_rows_in_partition_table(staging_name).zero?
200
+
201
+ @executor.move_all_rows_to_parent!(table_name, staging_name, conflict_key, cursor_columns: cursor_columns)
202
+ end
203
+
204
+ def drop_removed_partitions!(operations)
205
+ operations.select { |operation| operation.action == :drop }.each do |operation|
206
+ partition_name = operation.attached_segment.name
207
+ next if Connection.partition_attached?(@config[:table_name], partition_name)
208
+
209
+ @executor.drop_table(partition_name) if Connection.partition_exists?(partition_name)
210
+ end
211
+ end
212
+
213
+ def detach_managed_tail_partitions!(staging_name, skip_names: [])
214
+ table_name = @config[:table_name]
215
+
216
+ managed_tail_partition_names.each do |partition_name|
217
+ next if skip_names.include?(partition_name)
218
+ next unless Connection.partition_attached?(table_name, partition_name)
219
+
220
+ @executor.detach_partition(table_name, partition_name)
221
+ @executor.move_all_rows_between_partitions!(partition_name, staging_name, conflict_key, cursor_columns: cursor_columns)
222
+ @executor.drop_table(partition_name)
223
+ end
224
+ end
225
+
226
+ def drain_default_tail_rows_into_staging!(staging_name)
227
+ default_name = default_partition_name(@config[:table_name])
228
+ return unless Connection.partition_exists?(default_name)
229
+
230
+ where_condition = strategy.rebalance_default_drain_where_condition
231
+ return if where_condition == "FALSE"
232
+ return if Connection.count_rows_in_partition(default_name, where_condition).zero?
233
+
234
+ @executor.drain_rows_between_partitions!(
235
+ default_name,
236
+ staging_name,
237
+ where_condition,
238
+ conflict_key,
239
+ cursor_columns: cursor_columns
240
+ )
241
+ end
242
+
243
+ def staging_row_count(staging_name)
244
+ return 0 unless Connection.partition_exists?(staging_name)
245
+
246
+ Connection.count_rows_in_partition_table(staging_name)
247
+ end
248
+
249
+ def start_run_record(plan_signature)
250
+ RunRecord.start(table_name: @config[:table_name], plan_signature: plan_signature)
251
+ end
252
+
253
+ def notify_resume(record)
254
+ PartitionGardener.configuration.notify(
255
+ "[PartitionGardener] Resuming rebalance for #{@config[:table_name]} at phase #{record.phase}",
256
+ context: {
257
+ table_name: @config[:table_name],
258
+ job: @job_class_name,
259
+ action: "resume",
260
+ phase: record.phase,
261
+ plan_signature: record.plan_signature
262
+ }
263
+ )
264
+ end
265
+
266
+ def notify_rebalance_complete(table_name, plan)
267
+ PartitionGardener.configuration.notify(
268
+ "[PartitionGardener] Rebalanced tail partitions for #{table_name}",
269
+ context: {
270
+ table_name: table_name,
271
+ layout: @config.fetch(:layout, :sliding_window),
272
+ segments: plan.segments.map(&:signature),
273
+ hot_buckets: plan.hot_buckets,
274
+ incremental: incremental_rebalance?
275
+ }
276
+ )
277
+ end
278
+
279
+ def analyze_parent_table!(table_name)
280
+ return unless analyze_after_rebalance?
281
+
282
+ Connection.connection.execute("ANALYZE #{Connection.quoted_table(table_name)}")
283
+ end
284
+
285
+ def analyze_after_rebalance?
286
+ @config.fetch(:analyze_after_rebalance, PartitionGardener.configuration.analyze_after_rebalance)
287
+ end
288
+ end
289
+ end
@@ -0,0 +1,51 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module PartitionGardener
5
+ class PlanDiff
6
+ Operation = Data.define(:action, :segment, :attached_segment)
7
+
8
+ def self.operations(attached_segments, target_segments)
9
+ new(attached_segments, target_segments).operations
10
+ end
11
+
12
+ def self.plan_signature(segments)
13
+ payload = segments.map(&:signature).sort_by(&:to_s).to_json
14
+ Digest::SHA256.hexdigest(payload)[0, 16]
15
+ end
16
+
17
+ def self.changed?(attached_segments, target_segments)
18
+ attached_segments.map(&:signature).sort != target_segments.map(&:signature).sort
19
+ end
20
+
21
+ def initialize(attached_segments, target_segments)
22
+ @attached_segments = attached_segments
23
+ @target_segments = target_segments
24
+ end
25
+
26
+ def operations
27
+ attached_by_name = @attached_segments.each_with_object({}) { |segment, index| index[segment.name] = segment }
28
+ target_by_name = @target_segments.each_with_object({}) { |segment, index| index[segment.name] = segment }
29
+ result = []
30
+
31
+ target_by_name.each_value do |segment|
32
+ attached = attached_by_name[segment.name]
33
+ result << if attached.nil?
34
+ Operation.new(:create, segment, nil)
35
+ elsif attached.signature == segment.signature
36
+ Operation.new(:keep, segment, attached)
37
+ else
38
+ Operation.new(:reshape, segment, attached)
39
+ end
40
+ end
41
+
42
+ attached_by_name.each_value do |attached|
43
+ next if target_by_name[attached.name]
44
+
45
+ result << Operation.new(:drop, nil, attached)
46
+ end
47
+
48
+ result
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,95 @@
1
+ require "json"
2
+
3
+ module PartitionGardener
4
+ class PlanReport
5
+ SCHEMA_VERSION = "1.0"
6
+
7
+ def self.build(config)
8
+ planner = Planner.new(config)
9
+ plan = planner.build
10
+ attached_segments = planner.attached_tail_segments
11
+ operations = PlanDiff.operations(attached_segments, plan.segments)
12
+ gaps = GapDetection.call(config[:table_name], config: config)
13
+
14
+ new(
15
+ table_name: config[:table_name],
16
+ layout: config.fetch(:layout, :sliding_window),
17
+ changed: PlanDiff.changed?(attached_segments, plan.segments),
18
+ plan_signature: PlanDiff.plan_signature(plan.segments),
19
+ target_segments: plan.segments,
20
+ attached_segments: attached_segments,
21
+ operations: operations,
22
+ gaps: gaps,
23
+ hot_buckets: plan.hot_buckets
24
+ )
25
+ end
26
+
27
+ attr_reader :table_name,
28
+ :layout,
29
+ :changed,
30
+ :plan_signature,
31
+ :target_segments,
32
+ :attached_segments,
33
+ :operations,
34
+ :gaps,
35
+ :hot_buckets
36
+
37
+ def initialize(table_name:, layout:, changed:, plan_signature:, target_segments:, attached_segments:, operations:, gaps:, hot_buckets:)
38
+ @table_name = table_name
39
+ @layout = layout
40
+ @changed = changed
41
+ @plan_signature = plan_signature
42
+ @target_segments = target_segments
43
+ @attached_segments = attached_segments
44
+ @operations = operations
45
+ @gaps = gaps
46
+ @hot_buckets = hot_buckets
47
+ end
48
+
49
+ def to_h
50
+ {
51
+ schema_version: self.class::SCHEMA_VERSION,
52
+ table_name: table_name,
53
+ layout: layout,
54
+ changed: changed,
55
+ plan_signature: plan_signature,
56
+ target_segments: target_segments.map { |segment| segment_to_h(segment) },
57
+ attached_segments: attached_segments.map { |segment| segment_to_h(segment) },
58
+ operations: operations.map { |operation| operation_to_h(operation) },
59
+ gaps: gaps.map { |gap| gap_to_h(gap) },
60
+ hot_buckets: hot_buckets
61
+ }
62
+ end
63
+
64
+ def to_json(...)
65
+ to_h.to_json(...)
66
+ end
67
+
68
+ private
69
+
70
+ def segment_to_h(segment)
71
+ {
72
+ name: segment.name,
73
+ range_start: segment.range_start,
74
+ range_end: segment.range_end,
75
+ kind: segment.kind
76
+ }
77
+ end
78
+
79
+ def operation_to_h(operation)
80
+ {
81
+ action: operation.action,
82
+ segment: operation.segment ? segment_to_h(operation.segment) : nil,
83
+ attached_segment: operation.attached_segment ? segment_to_h(operation.attached_segment) : nil
84
+ }
85
+ end
86
+
87
+ def gap_to_h(gap)
88
+ {
89
+ range_start: gap.range_start,
90
+ range_end: gap.range_end,
91
+ message: gap.message
92
+ }
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,21 @@
1
+ module PartitionGardener
2
+ class Planner
3
+ def initialize(config)
4
+ @config = config
5
+ end
6
+
7
+ def build
8
+ strategy.build_plan
9
+ end
10
+
11
+ def attached_tail_segments
12
+ strategy.attached_tail_segments
13
+ end
14
+
15
+ private
16
+
17
+ def strategy
18
+ Strategy.for(@config)
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,85 @@
1
+ module PartitionGardener
2
+ module Predicate
3
+ COLUMN_PATTERN = /\A[a-z_][a-z0-9_]*\z/i
4
+ OPERATORS = %w[eq ne is_null is_not_null].freeze
5
+
6
+ module_function
7
+
8
+ def render(predicate, connection: nil)
9
+ connection ||= Connection.connection
10
+ predicate = predicate.transform_keys(&:to_sym)
11
+ column = predicate.fetch(:column)
12
+ operator = predicate.fetch(:operator).to_s
13
+
14
+ validate_column!(column)
15
+ validate_operator!(operator)
16
+
17
+ quoted_column = connection.quote_column_name(column)
18
+
19
+ case operator
20
+ when "eq"
21
+ "#{quoted_column} = #{connection.quote(predicate.fetch(:value))}"
22
+ when "ne"
23
+ "#{quoted_column} <> #{connection.quote(predicate.fetch(:value))}"
24
+ when "is_null"
25
+ "#{quoted_column} IS NULL"
26
+ when "is_not_null"
27
+ "#{quoted_column} IS NOT NULL"
28
+ end
29
+ end
30
+
31
+ def normalize_branch!(branch, discriminator_column: nil, connection: nil)
32
+ entry = branch.transform_keys(&:to_sym)
33
+ entry = entry.dup
34
+
35
+ if entry[:predicate]
36
+ entry[:where_condition] = render(entry[:predicate], connection: connection)
37
+ entry.delete(:predicate)
38
+ elsif entry[:where_condition]
39
+ entry[:where_condition] = entry[:where_condition].to_s
40
+ elsif (column = entry[:discriminator_column] || discriminator_column) && entry.key?(:value)
41
+ entry[:where_condition] = render(
42
+ {column: column, operator: "eq", value: entry[:value]},
43
+ connection: connection
44
+ )
45
+ else
46
+ name = entry[:name] || "?"
47
+ raise ArgumentError,
48
+ "branch #{name.inspect} needs predicate, where_condition, or value with discriminator_column"
49
+ end
50
+
51
+ entry
52
+ end
53
+
54
+ def normalize_branches!(branches, discriminator_column: nil, connection: nil)
55
+ branches.map do |branch|
56
+ normalize_branch!(branch, discriminator_column: discriminator_column, connection: connection)
57
+ end
58
+ end
59
+
60
+ def list_branch_entries(branches, discriminator_column: nil, connection: nil)
61
+ normalize_branches!(branches, discriminator_column: discriminator_column, connection: connection).map do |branch|
62
+ {
63
+ name: branch.fetch(:name),
64
+ value: branch.fetch(:value),
65
+ where_condition: branch.fetch(:where_condition)
66
+ }
67
+ end
68
+ end
69
+
70
+ def validate_column!(column)
71
+ name = column.to_s
72
+ return if COLUMN_PATTERN.match?(name)
73
+
74
+ raise ArgumentError, "predicate column must be a simple identifier, got #{name.inspect}"
75
+ end
76
+
77
+ def validate_operator!(operator)
78
+ return if OPERATORS.include?(operator)
79
+
80
+ raise ArgumentError, "unsupported predicate operator: #{operator.inspect} (allowed: #{OPERATORS.join(", ")})"
81
+ end
82
+
83
+ private :validate_column!, :validate_operator!
84
+ end
85
+ end
@@ -0,0 +1,44 @@
1
+ module PartitionGardener
2
+ class PremakeMonthlyMaintenance < DateRangeMaintenance
3
+ def run!
4
+ report_audit_warnings
5
+ ensure_default_partition
6
+ ensure_premade_months!
7
+ apply_archive_retention!
8
+ drain_default_partition
9
+ end
10
+
11
+ private
12
+
13
+ def ensure_premade_months!
14
+ return unless Connection.table_is_partitioned?(table_name)
15
+
16
+ month_count = @config.fetch(:premake_months, 3)
17
+ start_month = DateCalendar.beginning_of_month(PartitionGardener.configuration.today)
18
+
19
+ (0..month_count).each do |offset|
20
+ identifier = DateCalendar.add_months(start_month, offset)
21
+ ensure_month_partition!(identifier)
22
+ end
23
+ end
24
+
25
+ def ensure_month_partition!(identifier)
26
+ partition_name = @config[:partition_name_format].call(identifier)
27
+ for_values_clause = @config[:partition_definition].call(identifier)
28
+
29
+ return if Connection.partition_attached?(table_name, partition_name)
30
+
31
+ if Connection.partition_exists?(partition_name)
32
+ send(
33
+ :attach_archive_partition!,
34
+ table_name,
35
+ partition_name,
36
+ for_values_clause,
37
+ strategy.bucket_where_condition(identifier)
38
+ )
39
+ else
40
+ @executor.create_partition(table_name, partition_name, for_values_clause)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,12 @@
1
+ require "rails"
2
+
3
+ module PartitionGardener
4
+ class Railtie < Rails::Railtie
5
+ initializer "partition_gardener.connection" do
6
+ PartitionGardener.configure do |config|
7
+ config.connection_resolver = -> { ActiveRecord::Base.connection }
8
+ config.today_resolver = -> { Time.zone.today }
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,84 @@
1
+ module PartitionGardener
2
+ module Registry
3
+ class << self
4
+ def register(config)
5
+ normalized = Templates.normalize(config)
6
+ normalized[:maintenance_backend] = MaintenanceBackend.normalize(normalized[:maintenance_backend])
7
+ tables.delete_if { |entry| entry[:table_name] == normalized[:table_name] }
8
+ tables << normalized
9
+ MaintenanceBackend.validate!(normalized)
10
+ normalized
11
+ end
12
+
13
+ def register_template(template_name, **options)
14
+ builder = Templates.public_method(template_name)
15
+ register(builder.call(**options))
16
+ rescue NameError
17
+ raise ArgumentError, "unknown template: #{template_name.inspect}"
18
+ end
19
+
20
+ def register_all(configs)
21
+ configs.map { |config| register(config) }
22
+ end
23
+
24
+ def each_table_config(&block)
25
+ expanded_table_configs.each(&block)
26
+ end
27
+
28
+ def expanded_table_configs
29
+ expanded_tables
30
+ end
31
+
32
+ def configs_for_table(table_name)
33
+ name = table_name.to_s
34
+ registered = tables.find { |config| config[:table_name].to_s == name }
35
+ return Templates.expand(registered) if registered&.fetch(:layout, nil) == :composite
36
+
37
+ expanded_table_configs.select do |config|
38
+ config[:table_name].to_s == name || config[:parent_table_name].to_s == name
39
+ end
40
+ end
41
+
42
+ def find_by_table_name(table_name)
43
+ expanded_tables.find { |config| config[:table_name].to_s == table_name.to_s }
44
+ end
45
+
46
+ def hot_switch_partition_config(table_name)
47
+ config = find_by_table_name(table_name)
48
+ return nil unless config
49
+
50
+ {
51
+ table_name: config[:table_name],
52
+ partition_name_format: config[:partition_name_format],
53
+ partition_definition: config[:partition_definition],
54
+ partitions_to_create: lambda { |today|
55
+ [DateCalendar.beginning_of_month(today), DateCalendar.beginning_of_month(DateCalendar.next_month(today))]
56
+ }
57
+ }
58
+ end
59
+
60
+ def tables
61
+ @tables ||= []
62
+ end
63
+
64
+ def map(&block)
65
+ each_table_config.map(&block)
66
+ end
67
+
68
+ # Prefer `.tables`. Kept for callers that used the old constant name.
69
+ def self.TABLES
70
+ tables
71
+ end
72
+
73
+ def reset!
74
+ @tables = []
75
+ end
76
+
77
+ private
78
+
79
+ def expanded_tables
80
+ tables.flat_map { |config| Templates.expand(config) }
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,10 @@
1
+ module PartitionGardener
2
+ class RunFailed < StandardError
3
+ attr_reader :errors
4
+
5
+ def initialize(errors)
6
+ @errors = errors
7
+ super("Partition maintenance failed for #{errors.size} table(s)")
8
+ end
9
+ end
10
+ end