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,297 @@
1
+ module PartitionGardener
2
+ class DateRangeMaintenance
3
+ include Naming
4
+
5
+ def initialize(config, job_class_name: "PartitionGardener", executor: nil)
6
+ @config = config
7
+ @job_class_name = job_class_name
8
+ @executor = executor || Executor.for_config(config)
9
+ end
10
+
11
+ def run!
12
+ report_audit_warnings
13
+ ensure_default_partition
14
+ unless MaintenanceBackend.hybrid?(@config)
15
+ finalize_archive_partitions
16
+ apply_archive_retention!
17
+ end
18
+ rebalance_tail!
19
+ drain_default_partition
20
+ end
21
+
22
+ def split_future_month_from_current!(_identifier = nil)
23
+ rebalance_tail!
24
+ end
25
+
26
+ def split_pressured_future_month_partitions
27
+ rebalance_tail!
28
+ end
29
+
30
+ def collapse_low_volume_future_month_partitions
31
+ rebalance_tail!
32
+ end
33
+
34
+ private
35
+
36
+ def strategy
37
+ Strategy.for(@config)
38
+ end
39
+
40
+ def connection
41
+ Connection.connection
42
+ end
43
+
44
+ def conflict_key
45
+ @config[:conflict_key] || begin
46
+ [@config[:partition_key_column].split("::").first.strip, "id"].uniq
47
+ end
48
+ end
49
+
50
+ def cursor_columns
51
+ strategy.cursor_columns
52
+ end
53
+
54
+ def table_name
55
+ @config[:table_name]
56
+ end
57
+
58
+ def notify_error(error, action)
59
+ PartitionGardener.configuration.notify(
60
+ error,
61
+ context: {
62
+ table_name: table_name,
63
+ job: @job_class_name,
64
+ action: action
65
+ }
66
+ )
67
+ end
68
+
69
+ def rebalance_tail!
70
+ return unless Connection.table_is_partitioned?(table_name)
71
+
72
+ plan = strategy.build_plan
73
+ PlanApplier.new(@config, executor: @executor, job_class_name: @job_class_name).apply!(plan)
74
+ rescue => error
75
+ notify_error(error, "rebalance_tail")
76
+ raise
77
+ end
78
+
79
+ def ensure_default_partition
80
+ DefaultPartition.ensure!(@config, executor: @executor)
81
+ rescue => error
82
+ notify_error(error, "ensure_default_partition")
83
+ raise
84
+ end
85
+
86
+ def finalize_archive_partitions
87
+ return unless Connection.table_is_partitioned?(table_name)
88
+ return if strategy.is_a?(Strategy::HashBranches)
89
+ return if strategy.is_a?(Strategy::ListSplit)
90
+ return if strategy.is_a?(Strategy::IntegerRange)
91
+
92
+ source_name = archive_finalize_source_partition_name
93
+ return unless source_name
94
+ return unless Connection.partition_exists?(source_name)
95
+
96
+ Connection.get_distinct_partition_identifiers(
97
+ source_name,
98
+ @config[:partition_key_column],
99
+ @config[:extract_partition_identifier]
100
+ ).each do |identifier|
101
+ next unless strategy.archive_bucket?(identifier)
102
+
103
+ finalize_archive_from_source!(identifier, source_name)
104
+ end
105
+ rescue => error
106
+ notify_error(error, "finalize_archive_partitions")
107
+ raise
108
+ end
109
+
110
+ def archive_finalize_source_partition_name
111
+ current_name = current_partition_name(table_name)
112
+ return current_name if Connection.partition_exists?(current_name)
113
+
114
+ default_partition_name(table_name) if Connection.partition_exists?(default_partition_name(table_name))
115
+ end
116
+
117
+ def finalize_archive_from_source!(identifier, source_partition_name)
118
+ partition_name = archive_partition_name(identifier)
119
+ where_condition = strategy.bucket_where_condition(identifier)
120
+
121
+ ensure_archive_partition_attached!(identifier, partition_name: partition_name, source_partition_name: source_partition_name)
122
+
123
+ record_count = Connection.count_rows_in_partition(source_partition_name, where_condition)
124
+ return if record_count.zero?
125
+
126
+ @executor.move_rows_to_parent_partition!(
127
+ table_name: table_name,
128
+ source_partition_name: source_partition_name,
129
+ where_condition: where_condition,
130
+ destination_partition_name: partition_name,
131
+ conflict_key: conflict_key,
132
+ record_count: record_count,
133
+ cursor_columns: cursor_columns
134
+ )
135
+ end
136
+
137
+ def archive_partition_name(identifier)
138
+ if @config[:archive_partition_name_format]
139
+ @config[:archive_partition_name_format].call(identifier)
140
+ else
141
+ @config[:partition_name_format].call(identifier)
142
+ end
143
+ end
144
+
145
+ def drain_default_partition
146
+ return unless Connection.table_is_partitioned?(table_name)
147
+
148
+ default_name = default_partition_name(table_name)
149
+ return unless Connection.partition_exists?(default_name)
150
+
151
+ default_row_count = Connection.count_rows_in_partition_table(default_name)
152
+ return if default_row_count.zero?
153
+
154
+ PartitionGardener.configuration.notify(
155
+ "[PartitionGardener] Found #{default_row_count} rows in #{default_name}",
156
+ context: {
157
+ table_name: table_name,
158
+ default_partition_name: default_name,
159
+ count: default_row_count
160
+ }
161
+ )
162
+
163
+ unless strategy.is_a?(Strategy::HashBranches) || strategy.is_a?(Strategy::ListSplit)
164
+ bucket_counts(default_name).each do |identifier, row_count|
165
+ if strategy.archive_bucket?(identifier)
166
+ finalize_archive_from_source!(identifier, default_name)
167
+ elsif strategy.future_bucket?(identifier) && row_count >= split_row_threshold
168
+ finalize_archive_from_source!(identifier, default_name)
169
+ end
170
+ end
171
+ end
172
+
173
+ skip_bucket_drain = strategy.is_a?(Strategy::HashBranches) || strategy.is_a?(Strategy::ListSplit)
174
+ drain_remaining_default_rows!(known_row_count: skip_bucket_drain ? default_row_count : nil)
175
+ rescue => error
176
+ notify_error(error, "drain_default_partition")
177
+ raise
178
+ end
179
+
180
+ def drain_remaining_default_rows!(known_row_count: nil)
181
+ default_name = default_partition_name(table_name)
182
+ where_condition = strategy.default_partition_drain_where_condition
183
+ return if where_condition == "FALSE"
184
+
185
+ record_count = known_row_count || Connection.count_rows_in_partition(default_name, where_condition)
186
+ return if record_count.zero?
187
+
188
+ @executor.move_rows_to_parent_partition!(
189
+ table_name: table_name,
190
+ source_partition_name: default_name,
191
+ where_condition: where_condition,
192
+ destination_partition_name: table_name,
193
+ conflict_key: conflict_key,
194
+ record_count: record_count,
195
+ cursor_columns: cursor_columns
196
+ )
197
+ end
198
+
199
+ def split_row_threshold
200
+ @config.fetch(:split_row_threshold, FUTURE_MONTH_PARTITION_ROW_THRESHOLD)
201
+ end
202
+
203
+ def bucket_counts(partition_name)
204
+ if strategy.is_a?(Strategy::DateRange)
205
+ strategy.bucket_counts_in_partition(partition_name)
206
+ else
207
+ Connection.get_distinct_partition_identifiers(
208
+ partition_name,
209
+ @config[:partition_key_column],
210
+ @config[:extract_partition_identifier]
211
+ ).index_with do |identifier|
212
+ Connection.count_rows_in_partition(partition_name, strategy.bucket_where_condition(identifier))
213
+ end
214
+ end
215
+ end
216
+
217
+ def ensure_archive_partition_attached!(identifier, partition_name:, source_partition_name:)
218
+ for_values_clause = archive_for_values_clause(identifier)
219
+ where_condition = strategy.bucket_where_condition(identifier)
220
+
221
+ return if Connection.partition_attached?(table_name, partition_name)
222
+
223
+ if Connection.count_rows_in_partition(source_partition_name, where_condition).positive?
224
+ @executor.ensure_detached_partition_table!(table_name, partition_name, conflict_key: conflict_key)
225
+ @executor.drain_rows_between_partitions!(
226
+ source_partition_name,
227
+ partition_name,
228
+ where_condition,
229
+ conflict_key,
230
+ cursor_columns: cursor_columns
231
+ )
232
+ end
233
+
234
+ if Connection.partition_exists?(partition_name)
235
+ attach_archive_partition!(table_name, partition_name, for_values_clause, where_condition)
236
+ else
237
+ @executor.create_partition(table_name, partition_name, for_values_clause)
238
+ end
239
+ end
240
+
241
+ def attach_archive_partition!(table_name, partition_name, for_values_clause, where_condition)
242
+ @executor.attach_partition(table_name, partition_name, for_values_clause)
243
+ rescue => error
244
+ raise unless check_violation?(error)
245
+
246
+ default_name = default_partition_name(table_name)
247
+ if Connection.partition_exists?(default_name) &&
248
+ Connection.count_rows_in_partition(default_name, where_condition).positive?
249
+ @executor.drain_rows_between_partitions!(
250
+ default_name,
251
+ partition_name,
252
+ where_condition,
253
+ conflict_key,
254
+ cursor_columns: cursor_columns
255
+ )
256
+ end
257
+
258
+ @executor.attach_partition(table_name, partition_name, for_values_clause)
259
+ end
260
+
261
+ def check_violation?(error)
262
+ message = error.message.to_s
263
+ return true if message.include?("CheckViolation") || message.include?("check constraint")
264
+
265
+ error.cause && check_violation?(error.cause)
266
+ end
267
+
268
+ def report_audit_warnings
269
+ audit = Audit.call(table_name, config: @config)
270
+ audit.warnings.each do |warning|
271
+ PartitionGardener.configuration.notify(
272
+ "[PartitionGardener] #{warning}",
273
+ context: {
274
+ table_name: table_name,
275
+ job: @job_class_name,
276
+ action: "audit"
277
+ }
278
+ )
279
+ end
280
+ end
281
+
282
+ def apply_archive_retention!
283
+ ArchiveRetention.new(@config, executor: @executor, job_class_name: @job_class_name).apply!
284
+ end
285
+
286
+ def archive_for_values_clause(identifier)
287
+ if strategy.is_a?(Strategy::IntegerRange)
288
+ band = @config.fetch(:archive_band_size, Strategy::IntegerRange::DEFAULT_ARCHIVE_BAND_SIZE)
289
+ "FROM (#{identifier}) TO (#{identifier + band})"
290
+ else
291
+ @config[:partition_definition].call(identifier)
292
+ end
293
+ end
294
+ end
295
+
296
+ ThreeAreaMaintenance = DateRangeMaintenance
297
+ end
@@ -0,0 +1,54 @@
1
+ module PartitionGardener
2
+ class MissingDefaultPartition < StandardError; end
3
+
4
+ class DefaultPartition
5
+ include Naming
6
+
7
+ def self.ensure!(config, executor: nil)
8
+ new(config, executor: executor).ensure!
9
+ end
10
+
11
+ def initialize(config, executor: nil)
12
+ @config = config
13
+ @executor = executor || Executor.for_config(config)
14
+ end
15
+
16
+ def ensure!
17
+ table_name = @config[:table_name]
18
+ return unless Connection.table_is_partitioned?(table_name)
19
+ return unless Strategy.for(@config).default_partition_required?
20
+
21
+ default_name = default_partition_name(table_name)
22
+
23
+ if Connection.partition_exists?(default_name)
24
+ attach_existing_default!(table_name, default_name)
25
+ else
26
+ create_default!(table_name, default_name)
27
+ end
28
+
29
+ return if Connection.partition_attached?(table_name, default_name)
30
+
31
+ raise MissingDefaultPartition,
32
+ "Default partition #{default_name} is required for #{table_name} but is not attached"
33
+ end
34
+
35
+ private
36
+
37
+ def connection
38
+ Connection.connection
39
+ end
40
+
41
+ def attach_existing_default!(table_name, default_name)
42
+ return if Connection.partition_attached?(table_name, default_name)
43
+
44
+ @executor.attach_default_partition(table_name, default_name)
45
+ end
46
+
47
+ def create_default!(table_name, default_name)
48
+ sql = <<~SQL
49
+ CREATE TABLE IF NOT EXISTS #{Connection.quoted_table(default_name)} PARTITION OF #{Connection.quoted_table(table_name)} DEFAULT
50
+ SQL
51
+ connection.execute(sql)
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,324 @@
1
+ require "json"
2
+
3
+ module PartitionGardener
4
+ class Executor
5
+ def self.for_config(config, connection: Connection.connection)
6
+ new(
7
+ connection: connection,
8
+ batch_size: config.fetch(:move_batch_size, MOVE_BATCH_SIZE)
9
+ )
10
+ end
11
+
12
+ def initialize(connection: Connection.connection, batch_size: MOVE_BATCH_SIZE)
13
+ @connection = connection
14
+ @batch_size = batch_size
15
+ end
16
+
17
+ def attach_partition(table_name, partition_name, for_values_clause)
18
+ sql = <<~SQL
19
+ ALTER TABLE #{quoted_table(table_name)} ATTACH PARTITION #{quoted_table(partition_name)}
20
+ FOR VALUES #{for_values_clause}
21
+ SQL
22
+ @connection.execute(sql)
23
+ end
24
+
25
+ def attach_default_partition(table_name, partition_name)
26
+ sql = <<~SQL
27
+ ALTER TABLE #{quoted_table(table_name)} ATTACH PARTITION #{quoted_table(partition_name)} DEFAULT
28
+ SQL
29
+ @connection.execute(sql)
30
+ end
31
+
32
+ def detach_partition(table_name, partition_name, concurrently: false)
33
+ concurrently_keyword = concurrently ? "CONCURRENTLY " : ""
34
+ sql = <<~SQL
35
+ ALTER TABLE #{quoted_table(table_name)} DETACH PARTITION #{concurrently_keyword}#{quoted_table(partition_name)}
36
+ SQL
37
+ @connection.execute(sql)
38
+ end
39
+
40
+ def drop_table(table_name)
41
+ sql = <<~SQL
42
+ DROP TABLE IF EXISTS #{quoted_table(table_name)}
43
+ SQL
44
+ @connection.execute(sql)
45
+ end
46
+
47
+ def create_partition(table_name, partition_name, for_values_clause)
48
+ sql = <<~SQL
49
+ CREATE TABLE IF NOT EXISTS #{quoted_table(partition_name)} PARTITION OF #{quoted_table(table_name)}
50
+ FOR VALUES #{for_values_clause}
51
+ SQL
52
+ @connection.execute(sql)
53
+ end
54
+
55
+ def ensure_detached_partition_table!(table_name, partition_name, conflict_key:)
56
+ sql = <<~SQL
57
+ CREATE TABLE IF NOT EXISTS #{quoted_table(partition_name)} (
58
+ LIKE #{quoted_table(table_name)} INCLUDING ALL
59
+ )
60
+ SQL
61
+ @connection.execute(sql)
62
+
63
+ conflict_columns = conflict_key.map { |column| @connection.quote_column_name(column) }.join(", ")
64
+ sql = <<~SQL
65
+ CREATE UNIQUE INDEX IF NOT EXISTS #{@connection.quote_column_name("#{partition_name}_conflict_key_idx")}
66
+ ON #{quoted_table(partition_name)} (#{conflict_columns})
67
+ SQL
68
+ @connection.execute(sql)
69
+ end
70
+
71
+ def move_all_rows_between_partitions!(source_partition_name, destination_partition_name, conflict_key, cursor_columns: conflict_key)
72
+ move_rows_between_partitions!(
73
+ source_partition_name: source_partition_name,
74
+ destination_partition_name: destination_partition_name,
75
+ where_condition: nil,
76
+ conflict_key: conflict_key,
77
+ cursor_columns: cursor_columns
78
+ )
79
+ end
80
+
81
+ def drain_rows_between_partitions!(source_partition_name, destination_partition_name, where_condition, conflict_key, cursor_columns: conflict_key)
82
+ move_rows_between_partitions!(
83
+ source_partition_name: source_partition_name,
84
+ destination_partition_name: destination_partition_name,
85
+ where_condition: where_condition,
86
+ conflict_key: conflict_key,
87
+ cursor_columns: cursor_columns
88
+ )
89
+ end
90
+
91
+ def move_all_rows_to_parent!(table_name, source_partition_name, conflict_key, cursor_columns: conflict_key)
92
+ ensure_parent_conflict_index!(table_name, conflict_key)
93
+ move_rows_with_keyset!(
94
+ source_partition_name: source_partition_name,
95
+ insert_target: quoted_table(table_name),
96
+ where_condition: nil,
97
+ conflict_key: conflict_key,
98
+ cursor_columns: cursor_columns
99
+ )
100
+ end
101
+
102
+ def move_rows_to_parent_partition!(
103
+ table_name:,
104
+ source_partition_name:,
105
+ where_condition:,
106
+ destination_partition_name:,
107
+ conflict_key:,
108
+ record_count:,
109
+ cursor_columns: conflict_key
110
+ )
111
+ ensure_parent_conflict_index!(table_name, conflict_key)
112
+
113
+ PartitionGardener.configuration.notify(
114
+ "[PartitionGardener] Moving #{record_count} rows from #{source_partition_name} to #{destination_partition_name}",
115
+ context: {
116
+ table_name: table_name,
117
+ source_partition_name: source_partition_name,
118
+ destination_partition_name: destination_partition_name,
119
+ record_count: record_count
120
+ }
121
+ )
122
+
123
+ moved_rows = move_rows_with_keyset!(
124
+ source_partition_name: source_partition_name,
125
+ insert_target: quoted_table(table_name),
126
+ where_condition: where_condition,
127
+ conflict_key: conflict_key,
128
+ cursor_columns: cursor_columns
129
+ )
130
+
131
+ PartitionGardener.configuration.notify(
132
+ "[PartitionGardener] Moved #{moved_rows} rows from #{source_partition_name} to #{destination_partition_name}",
133
+ context: {
134
+ table_name: table_name,
135
+ source_partition_name: source_partition_name,
136
+ destination_partition_name: destination_partition_name,
137
+ moved_rows: moved_rows
138
+ }
139
+ )
140
+ end
141
+
142
+ private
143
+
144
+ def quoted_table(name)
145
+ @connection.quote_table_name(name)
146
+ end
147
+
148
+ def quoted_conflict_key(conflict_key)
149
+ conflict_key.map { |column| @connection.quote_column_name(column) }.join(", ")
150
+ end
151
+
152
+ def move_rows_between_partitions!(source_partition_name:, destination_partition_name:, where_condition:, conflict_key:, cursor_columns:)
153
+ move_rows_with_keyset!(
154
+ source_partition_name: source_partition_name,
155
+ insert_target: quoted_table(destination_partition_name),
156
+ where_condition: where_condition,
157
+ conflict_key: conflict_key,
158
+ cursor_columns: cursor_columns
159
+ )
160
+ end
161
+
162
+ def move_rows_with_keyset!(source_partition_name:, insert_target:, where_condition:, conflict_key:, cursor_columns:)
163
+ moved_rows = 0
164
+ last_cursor = nil
165
+
166
+ loop do
167
+ batch_result = execute_move_batch(
168
+ source_partition_name: source_partition_name,
169
+ insert_target: insert_target,
170
+ where_condition: where_condition,
171
+ conflict_key: conflict_key,
172
+ cursor_columns: cursor_columns,
173
+ last_cursor: last_cursor
174
+ )
175
+
176
+ moved_rows += batch_result[:deleted]
177
+ last_cursor = batch_result[:last_cursor]
178
+ if batch_result[:deleted].zero?
179
+ if batch_result[:batch_size].positive?
180
+ raise UnmovedRowsRemaining.new(
181
+ source_partition_name: source_partition_name,
182
+ batch_size: batch_result[:batch_size],
183
+ last_cursor: last_cursor
184
+ )
185
+ end
186
+
187
+ break
188
+ end
189
+ break if batch_result[:batch_size] < @batch_size
190
+ end
191
+
192
+ record_rows_moved!(moved_rows)
193
+ moved_rows
194
+ end
195
+
196
+ def record_rows_moved!(count)
197
+ PartitionGardener.configuration.current_run_metrics&.add_rows(count)
198
+ end
199
+
200
+ def execute_move_batch(source_partition_name:, insert_target:, where_condition:, conflict_key:, cursor_columns:, last_cursor:)
201
+ order_sql = cursor_columns.map { |column| @connection.quote_column_name(column) }.join(", ")
202
+ conflict_key_sql = quoted_conflict_key(conflict_key)
203
+ cursor_columns_sql = order_sql
204
+ inserted_not_exists_sql = column_match_sql("inserted_rows", "batch_rows", cursor_columns)
205
+ target_exists_sql = column_match_sql("target_rows", "batch_rows", conflict_key)
206
+ removable_match_sql = column_match_sql("source_rows", "removable_rows", cursor_columns)
207
+ returning_sql = cursor_columns.map { |column| "source_rows.#{@connection.quote_column_name(column)}" }.join(", ")
208
+
209
+ where_parts = []
210
+ where_parts << where_condition if where_condition
211
+ keyset_sql = keyset_after_clause(cursor_columns, last_cursor)
212
+ where_parts << keyset_sql if keyset_sql
213
+ where_clause = where_parts.any? ? "WHERE #{where_parts.join(" AND ")}" : ""
214
+
215
+ sql = <<~SQL
216
+ WITH batch_rows AS (
217
+ SELECT *
218
+ FROM #{quoted_table(source_partition_name)}
219
+ #{where_clause}
220
+ ORDER BY #{order_sql}
221
+ LIMIT #{@batch_size}
222
+ ),
223
+ inserted_rows AS (
224
+ INSERT INTO #{insert_target}
225
+ SELECT * FROM batch_rows
226
+ ON CONFLICT (#{conflict_key_sql}) DO NOTHING
227
+ RETURNING #{cursor_columns_sql}
228
+ ),
229
+ duplicates_at_target AS (
230
+ SELECT #{cursor_columns_sql}
231
+ FROM batch_rows
232
+ WHERE NOT EXISTS (
233
+ SELECT 1
234
+ FROM inserted_rows
235
+ WHERE #{inserted_not_exists_sql}
236
+ )
237
+ AND EXISTS (
238
+ SELECT 1
239
+ FROM #{insert_target} AS target_rows
240
+ WHERE #{target_exists_sql}
241
+ )
242
+ ),
243
+ removable_rows AS (
244
+ SELECT #{cursor_columns_sql} FROM inserted_rows
245
+ UNION
246
+ SELECT #{cursor_columns_sql} FROM duplicates_at_target
247
+ ),
248
+ deleted_rows AS (
249
+ DELETE FROM #{quoted_table(source_partition_name)} AS source_rows
250
+ USING removable_rows
251
+ WHERE #{removable_match_sql}
252
+ RETURNING #{returning_sql}
253
+ )
254
+ SELECT
255
+ (SELECT COUNT(*)::int FROM deleted_rows) AS deleted,
256
+ (SELECT COUNT(*)::int FROM batch_rows) AS batch_size,
257
+ (
258
+ SELECT row_to_json(last_row)
259
+ FROM (
260
+ SELECT #{order_sql}
261
+ FROM batch_rows
262
+ ORDER BY #{order_sql} DESC
263
+ LIMIT 1
264
+ ) AS last_row
265
+ ) AS last_cursor
266
+ SQL
267
+
268
+ row = @connection.execute(sql).first
269
+ deleted = row["deleted"].to_i
270
+ batch_size = row["batch_size"].to_i
271
+ last_cursor_values = parse_last_cursor(row["last_cursor"], cursor_columns)
272
+
273
+ {
274
+ deleted: deleted,
275
+ batch_size: batch_size,
276
+ last_cursor: last_cursor_values
277
+ }
278
+ end
279
+
280
+ def column_match_sql(left_alias, right_alias, columns)
281
+ columns.map do |column|
282
+ quoted_column = @connection.quote_column_name(column)
283
+ "#{left_alias}.#{quoted_column} = #{right_alias}.#{quoted_column}"
284
+ end.join(" AND ")
285
+ end
286
+
287
+ def keyset_after_clause(columns, last_values)
288
+ return nil if last_values.nil?
289
+
290
+ column_sql = columns.map { |column| @connection.quote_column_name(column) }.join(", ")
291
+ value_sql = last_values.map { |value| @connection.quote(value) }.join(", ")
292
+ "(#{column_sql}) > (#{value_sql})"
293
+ end
294
+
295
+ def parse_last_cursor(last_cursor_json, cursor_columns)
296
+ return nil if last_cursor_json.nil?
297
+
298
+ payload = last_cursor_json.is_a?(String) ? JSON.parse(last_cursor_json) : last_cursor_json
299
+ cursor_columns.map { |column| payload[column] || payload[column.to_s] }
300
+ end
301
+
302
+ def ensure_parent_conflict_index!(table_name, conflict_key)
303
+ return if Connection.unique_index_covers?(table_name, conflict_key)
304
+
305
+ index_name = "#{table_name}_#{conflict_key.join("_")}_maintenance_conflict_idx"
306
+ columns_sql = conflict_key.map { |column| @connection.quote_column_name(column) }.join(", ")
307
+ sql = <<~SQL
308
+ CREATE UNIQUE INDEX IF NOT EXISTS #{@connection.quote_column_name(index_name)}
309
+ ON #{quoted_table(table_name)} (#{columns_sql})
310
+ SQL
311
+ @connection.execute(sql)
312
+
313
+ return if Connection.unique_index_covers?(table_name, conflict_key)
314
+
315
+ raise MissingConflictIndex,
316
+ "#{table_name} needs a unique index on (#{conflict_key.join(", ")}) including the partition key for row moves"
317
+ rescue => error
318
+ raise if error.is_a?(MissingConflictIndex)
319
+
320
+ raise MissingConflictIndex,
321
+ "#{table_name} needs a unique index on (#{conflict_key.join(", ")}) including the partition key for row moves: #{error.message}"
322
+ end
323
+ end
324
+ end