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