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,190 @@
1
+ require "json"
2
+ require "optparse"
3
+
4
+ module PartitionGardener
5
+ class CLI
6
+ def self.start(argv = ARGV)
7
+ new(argv).run
8
+ end
9
+
10
+ def initialize(argv)
11
+ @argv = argv.dup
12
+ @options = {
13
+ pretty: false,
14
+ rails: false,
15
+ registry: nil,
16
+ table: nil,
17
+ all: false,
18
+ confirm: false
19
+ }
20
+ end
21
+
22
+ def run
23
+ parse_options!
24
+ command = @argv.shift || "help"
25
+ @options[:table] ||= @argv.first
26
+
27
+ case command
28
+ when "plan"
29
+ load_context!
30
+ print_json(plan_output)
31
+ when "audit"
32
+ load_context!
33
+ print_json(audit_output)
34
+ when "apply"
35
+ abort_with_help("apply requires --confirm (mutates the database)") unless @options[:confirm]
36
+ load_context!
37
+ print_json(apply_output)
38
+ when "help", "-h", "--help"
39
+ print_help
40
+ else
41
+ abort_with_help("unknown command: #{command}")
42
+ end
43
+ end
44
+
45
+ private
46
+
47
+ def parse_options!
48
+ OptionParser.new do |parser|
49
+ parser.banner = "Usage: partition_gardener [options] <plan|audit|apply> [table_name]"
50
+ parser.on("--table NAME", "Table name (default: first positional argument)") { |value| @options[:table] = value }
51
+ parser.on("--all", "All registered tables") { @options[:all] = true }
52
+ parser.on("--registry PATH", "Load registry from JSON file") { |value| @options[:registry] = value }
53
+ parser.on("--rails", "Load registry from Rails environment") { @options[:rails] = true }
54
+ parser.on("--pretty", "Pretty-print JSON") { @options[:pretty] = true }
55
+ parser.on("--confirm", "Required for apply (mutates the database)") { @options[:confirm] = true }
56
+ end.parse!(@argv)
57
+ end
58
+
59
+ def load_context!
60
+ if @options[:registry]
61
+ ConfigDocument.load_registry_file!(@options[:registry])
62
+ elsif @options[:rails]
63
+ load_rails!
64
+ elsif Registry.tables.empty?
65
+ abort_with_help("no tables registered; pass --registry PATH or --rails")
66
+ end
67
+ end
68
+
69
+ def load_rails!
70
+ root = find_rails_root(Dir.pwd) || abort("could not find Rails application root")
71
+ require File.join(root, "config", "environment")
72
+ end
73
+
74
+ def find_rails_root(start_directory)
75
+ directory = start_directory
76
+ loop do
77
+ return directory if File.exist?(File.join(directory, "config", "application.rb"))
78
+
79
+ parent = File.dirname(directory)
80
+ return nil if parent == directory
81
+
82
+ directory = parent
83
+ end
84
+ end
85
+
86
+ def plan_output
87
+ if @options[:all]
88
+ {
89
+ schema_version: PlanReport::SCHEMA_VERSION,
90
+ tables: PartitionGardener.send(:plan_all)
91
+ }
92
+ else
93
+ table_name = required_table_name!
94
+ configs = Registry.configs_for_table(table_name)
95
+ abort_with_help("no config for table #{table_name}") if configs.empty?
96
+
97
+ if configs.one?
98
+ PlanReport.build(configs.first).to_h
99
+ else
100
+ {
101
+ schema_version: PlanReport::SCHEMA_VERSION,
102
+ parent_table_name: table_name,
103
+ tables: configs.map { |config| PlanReport.build(config).to_h }
104
+ }
105
+ end
106
+ end
107
+ end
108
+
109
+ def audit_output
110
+ if @options[:all]
111
+ {
112
+ schema_version: Audit::SCHEMA_VERSION,
113
+ tables: Registry.expanded_table_configs.map { |config| audit_to_h(config[:table_name], config: config) }
114
+ }
115
+ else
116
+ table_name = required_table_name!
117
+ configs = Registry.configs_for_table(table_name)
118
+ abort_with_help("no config for table #{table_name}") if configs.empty?
119
+
120
+ if configs.one?
121
+ audit_to_h(table_name, config: configs.first)
122
+ else
123
+ {
124
+ schema_version: Audit::SCHEMA_VERSION,
125
+ parent_table_name: table_name,
126
+ tables: configs.map { |config| audit_to_h(config[:table_name], config: config) }
127
+ }
128
+ end
129
+ end
130
+ end
131
+
132
+ def apply_output
133
+ table_name = @options[:all] ? nil : @options[:table]
134
+ PartitionGardener.run!(table_name: table_name).to_h
135
+ end
136
+
137
+ def audit_to_h(table_name, config: nil)
138
+ result = Audit.call(table_name, config: config)
139
+ {
140
+ schema_version: Audit::SCHEMA_VERSION,
141
+ table_name: result.table_name,
142
+ partitioned: result.partitioned,
143
+ default_row_count: result.default_row_count,
144
+ attached_child_count: result.attached_child_count,
145
+ horizon_days: result.horizon_days,
146
+ gaps: result.gaps.map { |gap| {range_start: gap.range_start, range_end: gap.range_end, message: gap.message} },
147
+ warnings: result.warnings
148
+ }
149
+ end
150
+
151
+ def required_table_name!
152
+ table_name = @options[:table]
153
+ abort_with_help("table name is required (or pass --all)") unless table_name
154
+
155
+ table_name
156
+ end
157
+
158
+ def print_json(payload)
159
+ json = if @options[:pretty]
160
+ JSON.pretty_generate(payload)
161
+ else
162
+ JSON.generate(payload)
163
+ end
164
+
165
+ puts json
166
+ end
167
+
168
+ def print_help
169
+ puts <<~HELP
170
+ partition_gardener — plan, audit, and apply partition maintenance
171
+
172
+ Commands:
173
+ plan Print target layout diff as JSON (dry-run)
174
+ audit Print partition layout audit warnings as JSON
175
+ apply Run maintenance for registered tables (single table unless --all)
176
+
177
+ Examples:
178
+ partition_gardener --rails plan audits
179
+ partition_gardener --registry config/tables.json audit --all
180
+ partition_gardener --rails apply --confirm audits
181
+ HELP
182
+ end
183
+
184
+ def abort_with_help(message)
185
+ warn message
186
+ print_help
187
+ exit 1
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,240 @@
1
+ module PartitionGardener
2
+ module ConfigDocument
3
+ SCHEMA_PATH = File.expand_path("../../docs/schemas/partition_garden.schema.json", __dir__)
4
+ PLAN_SCHEMA_PATH = File.expand_path("../../docs/schemas/plan_report.schema.json", __dir__)
5
+
6
+ PORTABLE_KEYS = %i[
7
+ table_name
8
+ layout
9
+ bucket
10
+ partition_key_column
11
+ conflict_key
12
+ active_months
13
+ active_days
14
+ active_weeks
15
+ active_quarters
16
+ active_years
17
+ premake_months
18
+ split_row_threshold
19
+ move_batch_size
20
+ statement_timeout
21
+ retention_months
22
+ retention_apply
23
+ retention_keep_table
24
+ retention_detach_concurrently
25
+ hash_modulus
26
+ maintenance_backend
27
+ incremental_rebalance
28
+ run_record_enabled
29
+ analyze_after_rebalance
30
+ ].freeze
31
+
32
+ IMPORTABLE_LAYOUTS = %w[
33
+ sliding_window
34
+ rolling_current
35
+ calendar_year
36
+ premake_monthly
37
+ integer_window
38
+ hash_branches
39
+ ].freeze
40
+
41
+ ALLOWED_REGISTRY_KEYS = (
42
+ %w[table_name layout partition_key_column conflict_key bucket] +
43
+ PORTABLE_KEYS.map(&:to_s)
44
+ ).uniq.freeze
45
+
46
+ module_function
47
+
48
+ def export(config)
49
+ PORTABLE_KEYS.each_with_object({}) do |key, document|
50
+ next unless config.key?(key)
51
+
52
+ document[key] = config[key]
53
+ end
54
+ end
55
+
56
+ def export_all(configs)
57
+ configs.map { |config| export(config) }
58
+ end
59
+
60
+ def from_hash(document)
61
+ layout = document.fetch("layout").to_sym
62
+ table_name = document.fetch("table_name")
63
+ partition_key_column = document.fetch("partition_key_column")
64
+ conflict_key = document.fetch("conflict_key")
65
+ options = document.except("table_name", "layout", "partition_key_column", "conflict_key").transform_keys(&:to_sym)
66
+
67
+ case layout
68
+ when :sliding_window
69
+ import_sliding_window(
70
+ table_name: table_name,
71
+ partition_key_column: partition_key_column,
72
+ conflict_key: conflict_key,
73
+ **options
74
+ )
75
+ when :rolling_current
76
+ Templates.rolling_current_monthly(
77
+ table_name: table_name,
78
+ partition_key_column: partition_key_column,
79
+ conflict_key: conflict_key,
80
+ **options
81
+ )
82
+ when :premake_monthly
83
+ Templates.premake_monthly(
84
+ table_name: table_name,
85
+ partition_key_column: partition_key_column,
86
+ conflict_key: conflict_key,
87
+ **options
88
+ )
89
+ when :calendar_year
90
+ Templates.calendar_year(
91
+ table_name: table_name,
92
+ partition_key_column: partition_key_column,
93
+ conflict_key: conflict_key,
94
+ **options
95
+ )
96
+ when :integer_window
97
+ Templates.integer_window(
98
+ table_name: table_name,
99
+ partition_key_column: partition_key_column,
100
+ conflict_key: conflict_key,
101
+ **options
102
+ )
103
+ when :hash_branches
104
+ Templates.hash_branches(
105
+ table_name: table_name,
106
+ partition_key_column: partition_key_column,
107
+ conflict_key: conflict_key,
108
+ **options
109
+ )
110
+ else
111
+ raise ArgumentError, "layout #{layout} is not supported in JSON config import"
112
+ end
113
+ end
114
+
115
+ def import_sliding_window(table_name:, partition_key_column:, conflict_key:, bucket: :month, **options)
116
+ case bucket.to_sym
117
+ when :day
118
+ Templates.sliding_window_daily(
119
+ table_name: table_name,
120
+ partition_key_column: partition_key_column,
121
+ conflict_key: conflict_key,
122
+ **options
123
+ )
124
+ when :week
125
+ Templates.sliding_window_weekly(
126
+ table_name: table_name,
127
+ partition_key_column: partition_key_column,
128
+ conflict_key: conflict_key,
129
+ **options
130
+ )
131
+ when :quarter
132
+ Templates.sliding_window_quarterly(
133
+ table_name: table_name,
134
+ partition_key_column: partition_key_column,
135
+ conflict_key: conflict_key,
136
+ **options
137
+ )
138
+ else
139
+ Templates.sliding_window_monthly(
140
+ table_name: table_name,
141
+ partition_key_column: partition_key_column,
142
+ conflict_key: conflict_key,
143
+ **options
144
+ )
145
+ end
146
+ end
147
+
148
+ def load_registry_file!(path)
149
+ Registry.reset!
150
+ payload = JSON.parse(File.read(path))
151
+
152
+ case payload
153
+ when Array
154
+ payload.each { |document| register_validated_document!(document) }
155
+ when Hash
156
+ if payload["tables"].is_a?(Array)
157
+ payload["tables"].each { |document| register_validated_document!(document) }
158
+ else
159
+ register_validated_document!(payload)
160
+ end
161
+ else
162
+ raise ArgumentError, "registry file must be a JSON object or array"
163
+ end
164
+ end
165
+
166
+ def validate_registry_document!(document)
167
+ raise ArgumentError, "registry entry must be a JSON object" unless document.is_a?(Hash)
168
+
169
+ validate_registry_required_keys!(document)
170
+ validate_registry_unknown_keys!(document)
171
+ validate_registry_layout!(document)
172
+ validate_registry_core_strings!(document)
173
+ validate_registry_conflict_key!(document)
174
+ validate_registry_optional_bucket!(document)
175
+ validate_registry_optional_maintenance_backend!(document)
176
+ document
177
+ end
178
+
179
+ def validate_registry_required_keys!(document)
180
+ missing_keys = %w[table_name layout partition_key_column conflict_key] - document.keys
181
+ return if missing_keys.empty?
182
+
183
+ raise ArgumentError, "registry entry missing required keys: #{missing_keys.join(", ")}"
184
+ end
185
+
186
+ def validate_registry_unknown_keys!(document)
187
+ unknown_keys = document.keys - ALLOWED_REGISTRY_KEYS
188
+ return if unknown_keys.empty?
189
+
190
+ raise ArgumentError, "registry entry has unsupported keys: #{unknown_keys.join(", ")}"
191
+ end
192
+
193
+ def validate_registry_layout!(document)
194
+ layout = document.fetch("layout")
195
+ return if layout.is_a?(String) && IMPORTABLE_LAYOUTS.include?(layout)
196
+
197
+ supported = IMPORTABLE_LAYOUTS.join(", ")
198
+ raise ArgumentError, "layout #{layout.inspect} is not supported in JSON import (supported: #{supported}; composite and list_split require Ruby registration)"
199
+ end
200
+
201
+ def validate_registry_core_strings!(document)
202
+ unless document.fetch("table_name").is_a?(String) && !document.fetch("table_name").empty?
203
+ raise ArgumentError, "table_name must be a non-empty string"
204
+ end
205
+
206
+ unless document.fetch("partition_key_column").is_a?(String) && !document.fetch("partition_key_column").empty?
207
+ raise ArgumentError, "partition_key_column must be a non-empty string"
208
+ end
209
+ end
210
+
211
+ def validate_registry_conflict_key!(document)
212
+ conflict_key = document.fetch("conflict_key")
213
+ return if conflict_key.is_a?(Array) && conflict_key.any? && conflict_key.all? { |column| column.is_a?(String) && !column.empty? }
214
+
215
+ raise ArgumentError, "conflict_key must be a non-empty array of strings"
216
+ end
217
+
218
+ def validate_registry_optional_bucket!(document)
219
+ return unless document.key?("bucket")
220
+
221
+ bucket = document.fetch("bucket")
222
+ return if bucket.is_a?(String) && %w[day week month quarter year].include?(bucket)
223
+
224
+ raise ArgumentError, "bucket must be one of: day, week, month, quarter, year"
225
+ end
226
+
227
+ def validate_registry_optional_maintenance_backend!(document)
228
+ return unless document.key?("maintenance_backend")
229
+
230
+ backend = document.fetch("maintenance_backend")
231
+ return if backend.is_a?(String) && %w[gardener pg_partman hybrid_layout_only].include?(backend)
232
+
233
+ raise ArgumentError, "maintenance_backend must be gardener, pg_partman, or hybrid_layout_only"
234
+ end
235
+
236
+ def register_validated_document!(document)
237
+ Registry.register(from_hash(validate_registry_document!(document)))
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,102 @@
1
+ module PartitionGardener
2
+ class Configuration
3
+ attr_accessor :notifier,
4
+ :connection_resolver,
5
+ :statement_timeout_wrapper,
6
+ :today_resolver,
7
+ :schema_name,
8
+ :continue_on_error,
9
+ :advisory_lock_mode,
10
+ :analyze_after_rebalance,
11
+ :incremental_rebalance,
12
+ :run_record_enabled,
13
+ :retention_detach_concurrently,
14
+ :strict_maintenance_backend_validation,
15
+ :current_run_metrics
16
+
17
+ attr_writer :run_record_store
18
+
19
+ def initialize
20
+ @notifier = ->(_message_or_error, context: {}) {}
21
+ @connection_resolver = method(:default_connection_resolver)
22
+ @statement_timeout_wrapper = ->(_timeout, &block) { block.call }
23
+ @today_resolver = -> { Date.today }
24
+ @schema_name = "public"
25
+ @continue_on_error = true
26
+ @advisory_lock_mode = :transaction
27
+ @analyze_after_rebalance = false
28
+ @incremental_rebalance = true
29
+ @run_record_enabled = true
30
+ @run_record_store = nil
31
+ @retention_detach_concurrently = false
32
+ @strict_maintenance_backend_validation = false
33
+ @current_run_metrics = nil
34
+ end
35
+
36
+ def run_record_store
37
+ @run_record_store ||= default_run_record_store
38
+ end
39
+
40
+ def connection
41
+ resolved = @connection_resolver.call
42
+ raise ArgumentError, "PartitionGardener connection is not configured" unless resolved
43
+
44
+ resolved
45
+ end
46
+
47
+ def notify(message_or_error, context: {})
48
+ @notifier.call(message_or_error, context: context)
49
+ end
50
+
51
+ def with_statement_timeout(timeout, &block)
52
+ @statement_timeout_wrapper.call(timeout, &block)
53
+ end
54
+
55
+ def today
56
+ @today_resolver.call
57
+ end
58
+
59
+ private
60
+
61
+ def default_run_record_store
62
+ return MemoryRunRecordStore.new unless sql_run_record_store_available?
63
+
64
+ SqlRunRecordStore.new
65
+ rescue
66
+ MemoryRunRecordStore.new
67
+ end
68
+
69
+ def default_connection_resolver
70
+ return ActiveRecord::Base.connection if defined?(ActiveRecord::Base)
71
+
72
+ database_url = ENV["DATABASE_URL"]
73
+ return nil if Blank.blank?(database_url)
74
+
75
+ @pg_connection ||= PgConnection.connect(database_url).tap do
76
+ # Standalone maintenance runs many statements; session locks avoid one long transaction.
77
+ @advisory_lock_mode = :session if @advisory_lock_mode == :transaction
78
+ end
79
+ end
80
+
81
+ def sql_run_record_store_available?
82
+ return true if defined?(ActiveRecord::Base)
83
+
84
+ Blank.present?(ENV["DATABASE_URL"])
85
+ end
86
+ end
87
+
88
+ class << self
89
+ def configuration
90
+ @configuration ||= Configuration.new
91
+ end
92
+
93
+ def configure
94
+ yield(configuration) if block_given?
95
+ configuration
96
+ end
97
+
98
+ def reset_configuration!
99
+ @configuration = Configuration.new
100
+ end
101
+ end
102
+ end