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,373 @@
1
+ module PartitionGardener
2
+ module Templates
3
+ module_function
4
+
5
+ def normalize(config)
6
+ config = config.dup
7
+ config[:layout] ||= :sliding_window
8
+ config[:bucket] ||= :month if date_layout?(config[:layout])
9
+ config
10
+ end
11
+
12
+ def expand(config)
13
+ normalized = normalize(config)
14
+ return [normalized] unless normalized[:layout] == :composite
15
+
16
+ configs = []
17
+ parent_mode = normalized.fetch(:parent_mode, :list)
18
+
19
+ case parent_mode
20
+ when :list
21
+ if normalized[:list_branches]
22
+ configs << list_split(
23
+ table_name: normalized[:table_name],
24
+ branches: normalized[:list_branches],
25
+ conflict_key: normalized[:conflict_key],
26
+ partition_key_column: normalized.fetch(:partition_key_column, normalized[:discriminator_column])
27
+ )
28
+ end
29
+ when :range
30
+ configs << range_parent_config_for(normalized)
31
+ end
32
+
33
+ normalized.fetch(:branches, []).each do |branch|
34
+ configs << branch_config_for(normalized, branch)
35
+ end
36
+
37
+ configs
38
+ end
39
+
40
+ def sliding_window_monthly(table_name:, partition_key_column:, conflict_key:, active_months: 12, split_row_threshold: nil, **options)
41
+ sliding_window_for_bucket(
42
+ :month,
43
+ table_name: table_name,
44
+ partition_key_column: partition_key_column,
45
+ conflict_key: conflict_key,
46
+ active_months: active_months,
47
+ split_row_threshold: split_row_threshold,
48
+ **options
49
+ )
50
+ end
51
+
52
+ def sliding_window_daily(table_name:, partition_key_column:, conflict_key:, active_days: 90, split_row_threshold: nil, **options)
53
+ sliding_window_for_bucket(
54
+ :day,
55
+ table_name: table_name,
56
+ partition_key_column: partition_key_column,
57
+ conflict_key: conflict_key,
58
+ active_days: active_days,
59
+ split_row_threshold: split_row_threshold,
60
+ **options
61
+ )
62
+ end
63
+
64
+ def sliding_window_weekly(table_name:, partition_key_column:, conflict_key:, active_weeks: 52, split_row_threshold: nil, **options)
65
+ sliding_window_for_bucket(
66
+ :week,
67
+ table_name: table_name,
68
+ partition_key_column: partition_key_column,
69
+ conflict_key: conflict_key,
70
+ active_weeks: active_weeks,
71
+ split_row_threshold: split_row_threshold,
72
+ **options
73
+ )
74
+ end
75
+
76
+ def sliding_window_quarterly(table_name:, partition_key_column:, conflict_key:, active_quarters: 8, split_row_threshold: nil, **options)
77
+ sliding_window_for_bucket(
78
+ :quarter,
79
+ table_name: table_name,
80
+ partition_key_column: partition_key_column,
81
+ conflict_key: conflict_key,
82
+ active_quarters: active_quarters,
83
+ split_row_threshold: split_row_threshold,
84
+ **options
85
+ )
86
+ end
87
+
88
+ def rolling_current_monthly(table_name:, partition_key_column:, conflict_key:, active_months: 12, **options)
89
+ sliding_window_monthly(
90
+ table_name: table_name,
91
+ partition_key_column: partition_key_column,
92
+ conflict_key: conflict_key,
93
+ active_months: active_months,
94
+ layout: :rolling_current,
95
+ split_row_threshold: Float::INFINITY,
96
+ **options
97
+ )
98
+ end
99
+
100
+ def premake_monthly(table_name:, partition_key_column:, conflict_key:, premake_months: 3, split_row_threshold: nil, **options)
101
+ normalize({
102
+ table_name: table_name,
103
+ layout: :premake_monthly,
104
+ bucket: :month,
105
+ partition_key_column: partition_key_column,
106
+ conflict_key: conflict_key,
107
+ premake_months: premake_months,
108
+ maintenance_backend: options.fetch(:maintenance_backend, :gardener),
109
+ partition_name_format: options.fetch(:partition_name_format) {
110
+ ->(identifier) { DateBucket.partition_name(table_name, identifier, :month) }
111
+ },
112
+ partition_definition: options.fetch(:partition_definition) {
113
+ ->(date) { DateBucket.partition_definition_clause(date, :month) }
114
+ },
115
+ extract_partition_identifier: options.fetch(:extract_partition_identifier) {
116
+ ->(date_value) { DateBucket.beginning_of_bucket(date_value, :month) }
117
+ }
118
+ }.merge(options.except(:partition_name_format, :partition_definition, :extract_partition_identifier)).tap do |config|
119
+ config[:split_row_threshold] = split_row_threshold if split_row_threshold
120
+ end)
121
+ end
122
+
123
+ def calendar_year(table_name:, partition_key_column:, conflict_key:, active_years: 2, split_row_threshold: nil, **options)
124
+ normalize({
125
+ table_name: table_name,
126
+ layout: :calendar_year,
127
+ bucket: :year,
128
+ partition_key_column: partition_key_column,
129
+ conflict_key: conflict_key,
130
+ active_years: active_years,
131
+ partition_name_format: options.fetch(:partition_name_format) {
132
+ ->(identifier) { DateBucket.partition_name(table_name, identifier, :year) }
133
+ },
134
+ partition_definition: options.fetch(:partition_definition) {
135
+ ->(date) { DateBucket.partition_definition_clause(date, :year) }
136
+ },
137
+ extract_partition_identifier: options.fetch(:extract_partition_identifier) {
138
+ ->(date_value) { DateBucket.beginning_of_bucket(date_value, :year) }
139
+ }
140
+ }.merge(options.except(:partition_name_format, :partition_definition, :extract_partition_identifier)).tap do |config|
141
+ config[:split_row_threshold] = split_row_threshold if split_row_threshold
142
+ end)
143
+ end
144
+
145
+ def integer_window(table_name:, partition_key_column:, conflict_key:, active_id_lo: 0, active_id_width: nil, current_band_size: nil, split_row_threshold: nil, **options)
146
+ normalize({
147
+ table_name: table_name,
148
+ layout: :integer_window,
149
+ partition_key_column: partition_key_column,
150
+ conflict_key: conflict_key,
151
+ active_id_lo: active_id_lo,
152
+ active_id_width: active_id_width || Strategy::IntegerRange::DEFAULT_ACTIVE_ID_WIDTH,
153
+ current_band_size: current_band_size || Strategy::IntegerRange::DEFAULT_CURRENT_BAND_SIZE,
154
+ archive_band_size: options.fetch(:archive_band_size, Strategy::IntegerRange::DEFAULT_ARCHIVE_BAND_SIZE)
155
+ }.merge(options).tap do |config|
156
+ config[:split_row_threshold] = split_row_threshold if split_row_threshold
157
+ end)
158
+ end
159
+
160
+ def hash_branches(table_name:, partition_key_column:, conflict_key:, hash_modulus: nil, split_row_threshold: nil, **options)
161
+ normalize({
162
+ table_name: table_name,
163
+ layout: :hash_branches,
164
+ partition_key_column: partition_key_column,
165
+ conflict_key: conflict_key,
166
+ hash_modulus: hash_modulus || Strategy::HashBranches::DEFAULT_MODULUS
167
+ }.merge(options).tap do |config|
168
+ config[:split_row_threshold] = split_row_threshold if split_row_threshold
169
+ end)
170
+ end
171
+
172
+ def list_split(table_name:, branches:, conflict_key:, partition_key_column: nil, **options)
173
+ discriminator_column = partition_key_column || branches.first&.fetch(:discriminator_column, nil)
174
+ normalized_branches = Predicate.normalize_branches!(
175
+ branches,
176
+ discriminator_column: discriminator_column
177
+ )
178
+
179
+ normalize({
180
+ table_name: table_name,
181
+ layout: :list_split,
182
+ branches: normalized_branches,
183
+ conflict_key: conflict_key,
184
+ partition_key_column: partition_key_column || discriminator_column || "id"
185
+ }.merge(options))
186
+ end
187
+
188
+ def composite_list_hash(parent_table:, discriminator_column:, branches:, conflict_key:, **options)
189
+ composite_with_branches(
190
+ parent_table: parent_table,
191
+ discriminator_column: discriminator_column,
192
+ branches: branches,
193
+ conflict_key: conflict_key,
194
+ parent_mode: :list,
195
+ default_child_layout: :hash_branches,
196
+ **options
197
+ )
198
+ end
199
+
200
+ def composite_list_range(parent_table:, discriminator_column:, branches:, conflict_key:, bucket: :month, **options)
201
+ composite_with_branches(
202
+ parent_table: parent_table,
203
+ discriminator_column: discriminator_column,
204
+ branches: branches,
205
+ conflict_key: conflict_key,
206
+ parent_mode: :list,
207
+ default_child_layout: :sliding_window,
208
+ default_child_bucket: bucket,
209
+ **options
210
+ )
211
+ end
212
+
213
+ def list_range(parent_table:, discriminator_column:, branches:, conflict_key:, **options)
214
+ composite_list_range(
215
+ parent_table: parent_table,
216
+ discriminator_column: discriminator_column,
217
+ branches: branches,
218
+ conflict_key: conflict_key,
219
+ **options
220
+ )
221
+ end
222
+
223
+ def composite_range_hash(parent_table:, partition_key_column:, branches:, conflict_key:, bucket: :month, **options)
224
+ composite_with_branches(
225
+ parent_table: parent_table,
226
+ partition_key_column: partition_key_column,
227
+ branches: branches,
228
+ conflict_key: conflict_key,
229
+ parent_mode: :range,
230
+ parent_bucket: bucket,
231
+ default_child_layout: :hash_branches,
232
+ **options
233
+ )
234
+ end
235
+
236
+ def composite_range_list(parent_table:, partition_key_column:, branches:, conflict_key:, bucket: :month, **options)
237
+ composite_with_branches(
238
+ parent_table: parent_table,
239
+ partition_key_column: partition_key_column,
240
+ branches: branches,
241
+ conflict_key: conflict_key,
242
+ parent_mode: :range,
243
+ parent_bucket: bucket,
244
+ default_child_layout: :list_split,
245
+ **options
246
+ )
247
+ end
248
+
249
+ def branch_config_for(parent_config, branch)
250
+ branch_table_name = "#{parent_config[:table_name]}_#{branch.fetch(:name)}"
251
+ child = branch.dup
252
+ child.delete(:name)
253
+ child[:table_name] = branch_table_name
254
+ child[:parent_table_name] = parent_config[:table_name]
255
+ child[:layout] ||= parent_config.fetch(:default_child_layout, :hash_branches)
256
+
257
+ if child[:layout] == :sliding_window
258
+ child[:bucket] ||= parent_config.fetch(:default_child_bucket, :month)
259
+ child[:partition_key_column] ||= branch.fetch(:partition_key_column)
260
+ return sliding_window_for_bucket(
261
+ child[:bucket],
262
+ table_name: branch_table_name,
263
+ partition_key_column: child[:partition_key_column],
264
+ conflict_key: parent_config[:conflict_key],
265
+ parent_table_name: parent_config[:table_name],
266
+ split_row_threshold: child[:split_row_threshold],
267
+ active_months: child[:active_months],
268
+ active_days: child[:active_days],
269
+ active_weeks: child[:active_weeks],
270
+ active_quarters: child[:active_quarters]
271
+ )
272
+ end
273
+
274
+ if child[:layout] == :list_split
275
+ child[:branches] = branch.fetch(:branches)
276
+ child[:conflict_key] = parent_config[:conflict_key]
277
+ return list_split(
278
+ table_name: branch_table_name,
279
+ branches: child[:branches],
280
+ conflict_key: child[:conflict_key],
281
+ partition_key_column: child[:partition_key_column],
282
+ parent_table_name: parent_config[:table_name]
283
+ )
284
+ end
285
+
286
+ normalize(child)
287
+ end
288
+
289
+ def date_layout?(layout)
290
+ layout == :sliding_window || layout == :calendar_year || layout == :premake_monthly || layout == :rolling_current
291
+ end
292
+
293
+ def sliding_window_for_bucket(bucket, table_name:, partition_key_column:, conflict_key:, split_row_threshold: nil, layout: :sliding_window, **options)
294
+ active_key = DateBucket.active_key(bucket)
295
+ active_value = options[active_key] || DateBucket.default_active_span(bucket)
296
+
297
+ normalize({
298
+ :table_name => table_name,
299
+ :layout => layout,
300
+ :bucket => bucket,
301
+ :partition_key_column => partition_key_column,
302
+ :conflict_key => conflict_key,
303
+ active_key => active_value,
304
+ :partition_name_format => options.fetch(:partition_name_format) {
305
+ ->(identifier) { DateBucket.partition_name(table_name, identifier, bucket) }
306
+ },
307
+ :partition_definition => options.fetch(:partition_definition) {
308
+ ->(date) { DateBucket.partition_definition_clause(date, bucket) }
309
+ },
310
+ :extract_partition_identifier => options.fetch(:extract_partition_identifier) {
311
+ ->(date_value) { DateBucket.beginning_of_bucket(date_value, bucket) }
312
+ }
313
+ }.merge(options.except(:partition_name_format, :partition_definition, :extract_partition_identifier, active_key)).tap do |config|
314
+ config[:split_row_threshold] = split_row_threshold if split_row_threshold
315
+ end)
316
+ end
317
+
318
+ def composite_with_branches(parent_table:, branches:, conflict_key:, parent_mode:, default_child_layout:, discriminator_column: nil, partition_key_column: nil, parent_bucket: :month, default_child_bucket: :month, **options)
319
+ list_branch_entries = Predicate.list_branch_entries(branches, discriminator_column: discriminator_column) if parent_mode == :list
320
+
321
+ child_branches = branches.map do |branch|
322
+ entry = {
323
+ name: branch.fetch(:name),
324
+ layout: branch.fetch(:layout, default_child_layout)
325
+ }
326
+ entry[:partition_key_column] = branch[:partition_key_column] if branch[:partition_key_column]
327
+ entry[:hash_modulus] = branch.fetch(:hash_modulus, Strategy::HashBranches::DEFAULT_MODULUS) if entry[:layout] == :hash_branches
328
+ entry[:bucket] = branch.fetch(:bucket, default_child_bucket) if entry[:layout] == :sliding_window
329
+ entry[:active_months] = branch[:active_months] if branch[:active_months]
330
+ entry[:active_days] = branch[:active_days] if branch[:active_days]
331
+ entry[:active_weeks] = branch[:active_weeks] if branch[:active_weeks]
332
+ entry[:active_quarters] = branch[:active_quarters] if branch[:active_quarters]
333
+ entry[:split_row_threshold] = branch[:split_row_threshold] if branch[:split_row_threshold]
334
+ entry[:branches] = branch[:branches] if branch[:branches]
335
+ entry
336
+ end
337
+
338
+ normalize({
339
+ table_name: parent_table,
340
+ layout: :composite,
341
+ parent_mode: parent_mode,
342
+ bucket: parent_bucket,
343
+ discriminator_column: discriminator_column,
344
+ partition_key_column: partition_key_column,
345
+ conflict_key: conflict_key,
346
+ default_child_layout: default_child_layout,
347
+ default_child_bucket: default_child_bucket,
348
+ list_branches: (parent_mode == :list) ? list_branch_entries : nil,
349
+ branches: child_branches
350
+ }.compact.merge(options))
351
+ end
352
+
353
+ def range_parent_config_for(parent_config)
354
+ bucket = parent_config.fetch(:bucket, :month)
355
+ base = {
356
+ table_name: parent_config[:table_name],
357
+ partition_key_column: parent_config.fetch(:partition_key_column),
358
+ conflict_key: parent_config[:conflict_key]
359
+ }
360
+ passthrough = parent_config.slice(
361
+ :active_months, :active_days, :active_weeks, :active_quarters, :active_years,
362
+ :split_row_threshold, :maintenance_backend, :retention_months, :move_batch_size,
363
+ :statement_timeout, :incremental_rebalance, :analyze_after_rebalance
364
+ )
365
+
366
+ if bucket == :year
367
+ calendar_year(**base, **passthrough)
368
+ else
369
+ sliding_window_for_bucket(bucket, **base, **passthrough)
370
+ end
371
+ end
372
+ end
373
+ end
@@ -0,0 +1,15 @@
1
+ module PartitionGardener
2
+ class UnmovedRowsRemaining < StandardError
3
+ attr_reader :source_partition_name, :batch_size, :last_cursor
4
+
5
+ def initialize(source_partition_name:, batch_size:, last_cursor:)
6
+ @source_partition_name = source_partition_name
7
+ @batch_size = batch_size
8
+ @last_cursor = last_cursor
9
+ super(
10
+ "Stopped moving rows from #{source_partition_name}: " \
11
+ "batch selected #{batch_size} row(s) but none could be removed"
12
+ )
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,3 @@
1
+ module PartitionGardener
2
+ VERSION = "0.3.0"
3
+ end
@@ -0,0 +1,215 @@
1
+ require "date"
2
+
3
+ require_relative "partition_gardener/version"
4
+ require_relative "partition_gardener/date_calendar"
5
+ require_relative "partition_gardener/date_bucket"
6
+ require_relative "partition_gardener/stdlib_extensions"
7
+ require "pg"
8
+ require_relative "partition_gardener/pg_connection"
9
+ require_relative "partition_gardener/blank"
10
+ require_relative "partition_gardener/predicate"
11
+ require_relative "partition_gardener/memory_run_record_store"
12
+ require_relative "partition_gardener/sql_run_record_store"
13
+ require_relative "partition_gardener/configuration"
14
+ require_relative "partition_gardener/naming"
15
+ require_relative "partition_gardener/plan"
16
+ require_relative "partition_gardener/connection"
17
+ require_relative "partition_gardener/executor"
18
+ require_relative "partition_gardener/default_partition"
19
+ require_relative "partition_gardener/strategy/requires_default_partition"
20
+ require_relative "partition_gardener/strategy/cursor_columns"
21
+ require_relative "partition_gardener/strategy/date_range"
22
+ require_relative "partition_gardener/strategy/integer_range"
23
+ require_relative "partition_gardener/strategy/hash_branches"
24
+ require_relative "partition_gardener/strategy/list_split"
25
+ require_relative "partition_gardener/strategy/composite"
26
+ require_relative "partition_gardener/strategy"
27
+ require_relative "partition_gardener/layout/three_area"
28
+ require_relative "partition_gardener/layout/zone_segments"
29
+ require_relative "partition_gardener/layout/sliding_window"
30
+ require_relative "partition_gardener/layout/calendar_year"
31
+ require_relative "partition_gardener/layout/integer_window"
32
+ require_relative "partition_gardener/planner"
33
+ require_relative "partition_gardener/plan_diff"
34
+ require_relative "partition_gardener/gap_detection"
35
+ require_relative "partition_gardener/plan_report"
36
+ require_relative "partition_gardener/run_record"
37
+ require_relative "partition_gardener/plan_applier"
38
+ require_relative "partition_gardener/hash_routing"
39
+ require_relative "partition_gardener/maintenance_backend"
40
+ require_relative "partition_gardener/run_metrics"
41
+ require_relative "partition_gardener/date_range_maintenance"
42
+ require_relative "partition_gardener/premake_monthly_maintenance"
43
+ require_relative "partition_gardener/templates"
44
+ require_relative "partition_gardener/advisory_lock"
45
+ require_relative "partition_gardener/lock_not_acquired"
46
+ require_relative "partition_gardener/missing_conflict_index"
47
+ require_relative "partition_gardener/orphaned_rebalance_staging"
48
+ require_relative "partition_gardener/unmoved_rows_remaining"
49
+ require_relative "partition_gardener/run_failed"
50
+ require_relative "partition_gardener/audit"
51
+ require_relative "partition_gardener/archive_retention"
52
+ require_relative "partition_gardener/registry"
53
+ require_relative "partition_gardener/config_document"
54
+ require_relative "partition_gardener/cli"
55
+ require_relative "partition_gardener/migration/hot_switch_concern"
56
+
57
+ module PartitionGardener
58
+ MOVE_BATCH_SIZE = 10_000
59
+ FUTURE_MONTH_PARTITION_ROW_THRESHOLD = 100_000
60
+ DEFAULT_STATEMENT_TIMEOUT = 300
61
+
62
+ class << self
63
+ def run!(statement_timeout: DEFAULT_STATEMENT_TIMEOUT, job_class_name: "PartitionGardener", continue_on_error: configuration.continue_on_error, dry_run: false, table_name: nil)
64
+ return plan_all if dry_run
65
+
66
+ errors = []
67
+ table_results = []
68
+ filter_table = table_name
69
+ configs = filter_table ? Registry.configs_for_table(filter_table) : Registry.expanded_table_configs
70
+
71
+ configs.each do |config|
72
+ table_results << run_single_table!(
73
+ config,
74
+ statement_timeout: statement_timeout,
75
+ job_class_name: job_class_name,
76
+ continue_on_error: continue_on_error,
77
+ errors: errors
78
+ )
79
+ end
80
+
81
+ summary = RunSummary.new(tables: table_results, errors: errors)
82
+ raise RunFailed, errors if errors.any?
83
+
84
+ summary
85
+ end
86
+
87
+ def audit(table_name)
88
+ configs = Registry.configs_for_table(table_name)
89
+ raise ArgumentError, "no config for #{table_name}" if configs.empty?
90
+
91
+ if configs.one?
92
+ Audit.call(table_name, config: configs.first)
93
+ else
94
+ configs.map { |config| Audit.call(config[:table_name], config: config) }
95
+ end
96
+ end
97
+
98
+ def plan(table_name: nil, config: nil)
99
+ if config
100
+ return PlanReport.build(config)
101
+ end
102
+
103
+ raise ArgumentError, "table_name or config is required" unless table_name
104
+
105
+ configs = Registry.configs_for_table(table_name)
106
+ raise ArgumentError, "no config for #{table_name}" if configs.empty?
107
+
108
+ if configs.one?
109
+ PlanReport.build(configs.first)
110
+ else
111
+ configs.map { |entry| PlanReport.build(entry) }
112
+ end
113
+ end
114
+
115
+ def maintenance_for(config, job_class_name: "PartitionGardener")
116
+ case config.fetch(:layout)
117
+ when :composite
118
+ CompositeMaintenance.new(config, job_class_name: job_class_name)
119
+ when :premake_monthly
120
+ PremakeMonthlyMaintenance.new(config, job_class_name: job_class_name)
121
+ else
122
+ ThreeAreaMaintenance.new(config, job_class_name: job_class_name)
123
+ end
124
+ end
125
+
126
+ def emit_run_metadata(metrics, job_class_name:)
127
+ configuration.notify(
128
+ "[PartitionGardener] Finished #{metrics.table_name}",
129
+ context: {
130
+ table_name: metrics.table_name,
131
+ job: job_class_name,
132
+ action: "run_metadata",
133
+ run_metadata: metrics.to_h
134
+ }
135
+ )
136
+ end
137
+
138
+ def plan_all
139
+ reports = []
140
+ Registry.each_table_config do |config|
141
+ next unless Connection.table_is_partitioned?(config[:table_name])
142
+
143
+ reports << PlanReport.build(config).to_h
144
+ end
145
+ reports
146
+ end
147
+
148
+ def plan_all_tables
149
+ Registry.each_table_config.map { |config| PlanReport.build(config) }
150
+ end
151
+
152
+ def run_single_table!(config, statement_timeout:, job_class_name:, continue_on_error:, errors:)
153
+ table_name = config[:table_name]
154
+ table_timeout = config.fetch(:statement_timeout, statement_timeout)
155
+ metrics = RunMetrics.new(table_name)
156
+
157
+ configuration.with_statement_timeout(table_timeout) do
158
+ Connection.clear_attached_partitions_cache!
159
+
160
+ unless Connection.table_is_partitioned?(table_name)
161
+ metrics.mark_skipped!("not_partitioned")
162
+ return metrics
163
+ end
164
+
165
+ if MaintenanceBackend.skipped?(config)
166
+ metrics.mark_skipped!("maintenance_backend_pg_partman")
167
+ return metrics
168
+ end
169
+
170
+ configuration.current_run_metrics = metrics
171
+
172
+ AdvisoryLock.with_table_lock(table_name) do
173
+ DefaultPartition.ensure!(config)
174
+ maintenance_for(config, job_class_name: job_class_name).run!
175
+ end
176
+
177
+ metrics.finish!
178
+ emit_run_metadata(metrics, job_class_name: job_class_name)
179
+ metrics
180
+ rescue LockNotAcquired => error
181
+ metrics.mark_skipped!("lock_not_acquired")
182
+ configuration.notify(
183
+ "[PartitionGardener] skipped #{table_name}: #{error.message}",
184
+ context: {
185
+ table_name: table_name,
186
+ job: job_class_name,
187
+ action: "lock"
188
+ }
189
+ )
190
+ metrics
191
+ rescue => error
192
+ metrics.finish!
193
+ configuration.notify(
194
+ error,
195
+ context: {
196
+ table_name: table_name,
197
+ job: job_class_name,
198
+ action: "run",
199
+ run_metadata: metrics.to_h
200
+ }
201
+ )
202
+ errors << error
203
+ raise unless continue_on_error
204
+
205
+ metrics
206
+ ensure
207
+ configuration.current_run_metrics = nil
208
+ end
209
+ end
210
+
211
+ private :plan_all, :emit_run_metadata, :run_single_table!
212
+ end
213
+ end
214
+
215
+ require_relative "partition_gardener/rails" if defined?(Rails::Railtie)
@@ -0,0 +1,19 @@
1
+ module PartitionGardener
2
+ VERSION: String
3
+
4
+ MOVE_BATCH_SIZE: Integer
5
+ FUTURE_MONTH_PARTITION_ROW_THRESHOLD: Integer
6
+ DEFAULT_STATEMENT_TIMEOUT: ActiveSupport::Duration
7
+
8
+ def self.run!: (
9
+ ?statement_timeout: ActiveSupport::Duration,
10
+ ?job_class_name: String,
11
+ ?continue_on_error: bool,
12
+ ?dry_run: bool,
13
+ ?table_name: String?
14
+ ) -> untyped
15
+
16
+ def self.audit: (String table_name) -> untyped
17
+ def self.plan: (?table_name: String?, ?config: Hash[Symbol, untyped]?) -> untyped
18
+ def self.maintenance_for: (Hash[Symbol, untyped] config, ?job_class_name: String) -> untyped
19
+ end