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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +36 -0
- data/LICENSE.md +21 -0
- data/README.md +203 -0
- data/SECURITY.md +27 -0
- data/docs/THIRD_PARTY_LICENSE_MANIFEST.tsv +60 -0
- data/docs/application_contract.md +82 -0
- data/docs/audit_reference.md +125 -0
- data/docs/background_job.md +161 -0
- data/docs/cli.md +71 -0
- data/docs/configuration.md +261 -0
- data/docs/cutover.md +180 -0
- data/docs/decision_flow.md +154 -0
- data/docs/host_testing.md +91 -0
- data/docs/monitoring.md +110 -0
- data/docs/naming.md +71 -0
- data/docs/operations.md +131 -0
- data/docs/partition_landscape.md +323 -0
- data/docs/pg_party_recipe.md +44 -0
- data/docs/related_postgres_tooling.md +195 -0
- data/docs/retention.md +65 -0
- data/docs/schemas/partition_garden.schema.json +114 -0
- data/docs/schemas/plan_report.schema.json +82 -0
- data/docs/tooling_split.md +79 -0
- data/exe/partition_gardener +6 -0
- data/lib/partition_gardener/active_record_run_record_store.rb +1 -0
- data/lib/partition_gardener/advisory_lock.rb +48 -0
- data/lib/partition_gardener/archive_retention.rb +88 -0
- data/lib/partition_gardener/audit.rb +93 -0
- data/lib/partition_gardener/blank.rb +13 -0
- data/lib/partition_gardener/cli.rb +190 -0
- data/lib/partition_gardener/config_document.rb +240 -0
- data/lib/partition_gardener/configuration.rb +102 -0
- data/lib/partition_gardener/connection.rb +260 -0
- data/lib/partition_gardener/date_bucket.rb +117 -0
- data/lib/partition_gardener/date_calendar.rb +65 -0
- data/lib/partition_gardener/date_range_maintenance.rb +297 -0
- data/lib/partition_gardener/default_partition.rb +54 -0
- data/lib/partition_gardener/executor.rb +324 -0
- data/lib/partition_gardener/gap_detection.rb +59 -0
- data/lib/partition_gardener/hash_routing.rb +70 -0
- data/lib/partition_gardener/layout/calendar_year.rb +28 -0
- data/lib/partition_gardener/layout/integer_window.rb +28 -0
- data/lib/partition_gardener/layout/sliding_window.rb +28 -0
- data/lib/partition_gardener/layout/three_area.rb +27 -0
- data/lib/partition_gardener/layout/zone_segments.rb +62 -0
- data/lib/partition_gardener/lock_not_acquired.rb +3 -0
- data/lib/partition_gardener/maintenance_backend.rb +73 -0
- data/lib/partition_gardener/memory_run_record_store.rb +19 -0
- data/lib/partition_gardener/migration/hot_switch_concern.rb +445 -0
- data/lib/partition_gardener/missing_conflict_index.rb +3 -0
- data/lib/partition_gardener/naming.rb +29 -0
- data/lib/partition_gardener/orphaned_rebalance_staging.rb +3 -0
- data/lib/partition_gardener/pg_connection.rb +94 -0
- data/lib/partition_gardener/plan.rb +49 -0
- data/lib/partition_gardener/plan_applier.rb +289 -0
- data/lib/partition_gardener/plan_diff.rb +51 -0
- data/lib/partition_gardener/plan_report.rb +95 -0
- data/lib/partition_gardener/planner.rb +21 -0
- data/lib/partition_gardener/predicate.rb +85 -0
- data/lib/partition_gardener/premake_monthly_maintenance.rb +44 -0
- data/lib/partition_gardener/rails.rb +12 -0
- data/lib/partition_gardener/registry.rb +84 -0
- data/lib/partition_gardener/run_failed.rb +10 -0
- data/lib/partition_gardener/run_metrics.rb +65 -0
- data/lib/partition_gardener/run_record.rb +76 -0
- data/lib/partition_gardener/sql_run_record_store.rb +106 -0
- data/lib/partition_gardener/stdlib_extensions.rb +15 -0
- data/lib/partition_gardener/strategy/composite.rb +27 -0
- data/lib/partition_gardener/strategy/cursor_columns.rb +18 -0
- data/lib/partition_gardener/strategy/date_range.rb +303 -0
- data/lib/partition_gardener/strategy/hash_branches.rb +161 -0
- data/lib/partition_gardener/strategy/integer_range.rb +261 -0
- data/lib/partition_gardener/strategy/list_split.rb +125 -0
- data/lib/partition_gardener/strategy/requires_default_partition.rb +19 -0
- data/lib/partition_gardener/strategy.rb +26 -0
- data/lib/partition_gardener/templates.rb +373 -0
- data/lib/partition_gardener/unmoved_rows_remaining.rb +15 -0
- data/lib/partition_gardener/version.rb +3 -0
- data/lib/partition_gardener.rb +215 -0
- data/sig/partition_gardener.rbs +19 -0
- 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
|