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