pcrd 0.1.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 +24 -0
- data/LICENSE +21 -0
- data/README.md +614 -0
- data/bin/pcrd +7 -0
- data/lib/pcrd/advisory_lock.rb +50 -0
- data/lib/pcrd/apply/engine.rb +184 -0
- data/lib/pcrd/apply/worker.rb +97 -0
- data/lib/pcrd/backfill/batch.rb +158 -0
- data/lib/pcrd/backfill/engine.rb +153 -0
- data/lib/pcrd/checkpoint/store.rb +217 -0
- data/lib/pcrd/cli.rb +274 -0
- data/lib/pcrd/commands/analyze.rb +125 -0
- data/lib/pcrd/commands/cleanup.rb +112 -0
- data/lib/pcrd/commands/demo.rb +152 -0
- data/lib/pcrd/commands/readiness.rb +30 -0
- data/lib/pcrd/commands/status.rb +129 -0
- data/lib/pcrd/commands/verify.rb +172 -0
- data/lib/pcrd/config/add_column.rb +7 -0
- data/lib/pcrd/config/analyze_config.rb +8 -0
- data/lib/pcrd/config/column_spec.rb +10 -0
- data/lib/pcrd/config/connection.rb +7 -0
- data/lib/pcrd/config/cutover_config.rb +7 -0
- data/lib/pcrd/config/load_error.rb +7 -0
- data/lib/pcrd/config/loader.rb +158 -0
- data/lib/pcrd/config/migrate_config.rb +21 -0
- data/lib/pcrd/config/root.rb +9 -0
- data/lib/pcrd/config/schema.rb +62 -0
- data/lib/pcrd/config/table.rb +9 -0
- data/lib/pcrd/config/verify_config.rb +7 -0
- data/lib/pcrd/config.rb +7 -0
- data/lib/pcrd/connection/client.rb +129 -0
- data/lib/pcrd/connection/error.rb +7 -0
- data/lib/pcrd/connection/replication.rb +108 -0
- data/lib/pcrd/cutover/orchestrator.rb +108 -0
- data/lib/pcrd/cutover/sequences.rb +138 -0
- data/lib/pcrd/demo/generator.rb +214 -0
- data/lib/pcrd/demo/schema.rb +154 -0
- data/lib/pcrd/error.rb +12 -0
- data/lib/pcrd/migration/orchestrator.rb +272 -0
- data/lib/pcrd/monitor/lag.rb +107 -0
- data/lib/pcrd/options.rb +15 -0
- data/lib/pcrd/output/analyze_printer.rb +173 -0
- data/lib/pcrd/output/cutover_printer.rb +128 -0
- data/lib/pcrd/output/preflight_printer.rb +119 -0
- data/lib/pcrd/output/readiness_printer.rb +72 -0
- data/lib/pcrd/preflight.rb +331 -0
- data/lib/pcrd/readiness/manifest.rb +201 -0
- data/lib/pcrd/replication/consumer.rb +235 -0
- data/lib/pcrd/replication/error.rb +10 -0
- data/lib/pcrd/replication/pgoutput/messages.rb +68 -0
- data/lib/pcrd/replication/pgoutput/parser.rb +316 -0
- data/lib/pcrd/reporter/console.rb +46 -0
- data/lib/pcrd/reporter/null.rb +14 -0
- data/lib/pcrd/schema/column.rb +59 -0
- data/lib/pcrd/schema/ddl.rb +71 -0
- data/lib/pcrd/schema/diff_entry.rb +36 -0
- data/lib/pcrd/schema/differ.rb +175 -0
- data/lib/pcrd/schema/object_reader.rb +187 -0
- data/lib/pcrd/schema/packer.rb +90 -0
- data/lib/pcrd/schema/reader.rb +118 -0
- data/lib/pcrd/schema/setup.rb +143 -0
- data/lib/pcrd/schema/setup_error.rb +9 -0
- data/lib/pcrd/schema/table_not_found.rb +8 -0
- data/lib/pcrd/schema/type_registry.rb +116 -0
- data/lib/pcrd/sql.rb +55 -0
- data/lib/pcrd/transform/row_transformer.rb +69 -0
- data/lib/pcrd/transform/type_map.rb +209 -0
- data/lib/pcrd/transform/validator.rb +106 -0
- data/lib/pcrd/version.rb +5 -0
- data/lib/pcrd.rb +11 -0
- metadata +231 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Pcrd
|
|
6
|
+
module Output
|
|
7
|
+
class PreflightPrinter
|
|
8
|
+
PASTEL = Pastel.new
|
|
9
|
+
|
|
10
|
+
ICONS = {
|
|
11
|
+
pass: PASTEL.green("✓"),
|
|
12
|
+
fail: PASTEL.red("✗"),
|
|
13
|
+
warn: PASTEL.yellow("⚠"),
|
|
14
|
+
info: PASTEL.dim("·")
|
|
15
|
+
}.freeze
|
|
16
|
+
|
|
17
|
+
def initialize(output: $stdout)
|
|
18
|
+
@out = output
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def print(result)
|
|
22
|
+
@out.puts
|
|
23
|
+
@out.puts PASTEL.bold("Preflight check")
|
|
24
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
25
|
+
@out.puts
|
|
26
|
+
|
|
27
|
+
result.items.each { |item| print_item(item) }
|
|
28
|
+
|
|
29
|
+
@out.puts
|
|
30
|
+
print_ddl_section(result.ddl_map) if result.ddl_map.any?
|
|
31
|
+
print_estimate_section(result.row_counts, result) if result.row_counts.any?
|
|
32
|
+
print_summary(result)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def print_item(item)
|
|
38
|
+
icon = ICONS[item.status]
|
|
39
|
+
label = case item.status
|
|
40
|
+
when :fail then PASTEL.red(item.label)
|
|
41
|
+
when :warn then PASTEL.yellow(item.label)
|
|
42
|
+
else item.label
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
if item.detail&.include?("\n")
|
|
46
|
+
@out.puts " #{icon} #{label}"
|
|
47
|
+
item.detail.each_line do |line|
|
|
48
|
+
@out.puts " #{PASTEL.dim(line.chomp)}"
|
|
49
|
+
end
|
|
50
|
+
elsif item.detail
|
|
51
|
+
@out.puts " #{icon} #{label} #{PASTEL.dim(item.detail)}"
|
|
52
|
+
else
|
|
53
|
+
@out.puts " #{icon} #{label}"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def print_ddl_section(ddl_map)
|
|
58
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
59
|
+
@out.puts
|
|
60
|
+
@out.puts PASTEL.bold(" Target DDL:")
|
|
61
|
+
@out.puts
|
|
62
|
+
ddl_map.each do |_table, ddl|
|
|
63
|
+
ddl.each_line { @out.puts " #{_1.chomp}" }
|
|
64
|
+
@out.puts " ;"
|
|
65
|
+
@out.puts
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def print_estimate_section(row_counts, result)
|
|
70
|
+
return unless result.respond_to?(:passed)
|
|
71
|
+
|
|
72
|
+
batch_size = 10_000 # default; real config batch size used by migrate
|
|
73
|
+
|
|
74
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
75
|
+
@out.puts
|
|
76
|
+
@out.puts PASTEL.bold(" Estimated backfill:")
|
|
77
|
+
@out.puts
|
|
78
|
+
|
|
79
|
+
row_counts.each do |table, count|
|
|
80
|
+
next if count.zero?
|
|
81
|
+
|
|
82
|
+
batches = (count.to_f / batch_size).ceil
|
|
83
|
+
est_mins = (batches / 100.0).round(1)
|
|
84
|
+
est_label = est_mins >= 60 ? "~#{(est_mins / 60).round(1)}h" : "~#{est_mins}m"
|
|
85
|
+
|
|
86
|
+
@out.puts " #{table}: #{format_count(count)} rows / " \
|
|
87
|
+
"#{format_count(batch_size)} per batch = " \
|
|
88
|
+
"#{format_count(batches)} batches (#{est_label} at 100 batches/min)"
|
|
89
|
+
end
|
|
90
|
+
@out.puts
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def print_summary(result)
|
|
94
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
95
|
+
@out.puts
|
|
96
|
+
|
|
97
|
+
fail_count = result.items.count { |i| i.status == :fail }
|
|
98
|
+
warn_count = result.items.count { |i| i.status == :warn }
|
|
99
|
+
|
|
100
|
+
if result.passed
|
|
101
|
+
if warn_count > 0
|
|
102
|
+
@out.puts " #{PASTEL.green("✓")} #{PASTEL.bold("All checks passed")} " \
|
|
103
|
+
"(#{PASTEL.yellow("#{warn_count} warning(s)")} — review before proceeding)"
|
|
104
|
+
else
|
|
105
|
+
@out.puts " #{PASTEL.green("✓")} #{PASTEL.bold("All checks passed.")}"
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
@out.puts " #{PASTEL.red("✗")} #{PASTEL.bold(PASTEL.red("#{fail_count} check(s) failed."))} " \
|
|
109
|
+
"Fix the issue(s) above before running migrate."
|
|
110
|
+
end
|
|
111
|
+
@out.puts
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def format_count(n)
|
|
115
|
+
n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
|
|
5
|
+
module Pcrd
|
|
6
|
+
module Output
|
|
7
|
+
# Renders a Readiness::Manifest::Result: a per-table checklist of secondary
|
|
8
|
+
# objects, followed by a single block of DDL to run on the target before
|
|
9
|
+
# cutover.
|
|
10
|
+
class ReadinessPrinter
|
|
11
|
+
PASTEL = Pastel.new
|
|
12
|
+
|
|
13
|
+
ICONS = {
|
|
14
|
+
present: PASTEL.green("✓"),
|
|
15
|
+
missing: PASTEL.yellow("+"),
|
|
16
|
+
needs_review: PASTEL.red("!"),
|
|
17
|
+
info: PASTEL.dim("·")
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(output: $stdout)
|
|
21
|
+
@out = output
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def print(result)
|
|
25
|
+
@out.puts
|
|
26
|
+
@out.puts PASTEL.bold("Target readiness")
|
|
27
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
28
|
+
|
|
29
|
+
result.tables.each { |table| print_table(table) }
|
|
30
|
+
|
|
31
|
+
print_ddl_section(result)
|
|
32
|
+
print_legend
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def print_table(table)
|
|
38
|
+
@out.puts
|
|
39
|
+
@out.puts PASTEL.bold(" #{table.table_name}")
|
|
40
|
+
if table.entries.empty?
|
|
41
|
+
@out.puts " #{PASTEL.dim('no secondary objects on source')}"
|
|
42
|
+
return
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
table.entries.each do |e|
|
|
46
|
+
@out.puts " #{ICONS[e.status]} #{e.category.ljust(10)} " \
|
|
47
|
+
"#{e.name.ljust(28)} #{PASTEL.dim(e.detail)}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def print_ddl_section(result)
|
|
52
|
+
ddl = result.tables.flat_map { |t| t.entries }.filter_map(&:ddl)
|
|
53
|
+
return if ddl.empty?
|
|
54
|
+
|
|
55
|
+
@out.puts
|
|
56
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
57
|
+
@out.puts
|
|
58
|
+
@out.puts PASTEL.bold(" DDL to run on the target before cutover:")
|
|
59
|
+
@out.puts
|
|
60
|
+
ddl.each { |stmt| @out.puts " #{stmt}" }
|
|
61
|
+
@out.puts
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def print_legend
|
|
65
|
+
@out.puts PASTEL.dim("─" * 70)
|
|
66
|
+
@out.puts " #{ICONS[:present]} present #{ICONS[:missing]} will create " \
|
|
67
|
+
"#{ICONS[:needs_review]} needs review #{ICONS[:info]} handled at cutover"
|
|
68
|
+
@out.puts
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pcrd
|
|
4
|
+
# Runs all pre-migration safety checks and generates the target DDL.
|
|
5
|
+
#
|
|
6
|
+
# Checks are grouped and run top-to-bottom. Connection failures are hard
|
|
7
|
+
# stops (subsequent checks that need a connection are skipped). All table
|
|
8
|
+
# checks run even when earlier tables fail, so the operator sees all
|
|
9
|
+
# problems at once.
|
|
10
|
+
#
|
|
11
|
+
# Returns a Result that includes all check items and a DDL map for display.
|
|
12
|
+
class Preflight
|
|
13
|
+
# Individual check result.
|
|
14
|
+
Item = Data.define(:status, :label, :detail)
|
|
15
|
+
# status: :pass | :fail | :warn | :info
|
|
16
|
+
|
|
17
|
+
# Overall preflight result.
|
|
18
|
+
Result = Data.define(:passed, :items, :ddl_map, :row_counts)
|
|
19
|
+
# ddl_map: Hash<table_name, String> — generated CREATE TABLE SQL per table
|
|
20
|
+
# row_counts: Hash<table_name, Integer> — estimated row counts
|
|
21
|
+
|
|
22
|
+
HARD_FAIL = :fail # any :fail in items means Result#passed = false
|
|
23
|
+
|
|
24
|
+
def initialize(config, options = {})
|
|
25
|
+
@config = config
|
|
26
|
+
@options = Options.normalize(options)
|
|
27
|
+
@items = []
|
|
28
|
+
@ddl_map = {}
|
|
29
|
+
@row_counts = {}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def run
|
|
33
|
+
@source_pool = open_pool(@config.source)
|
|
34
|
+
@target_pool = @config.target ? open_pool(@config.target) : nil
|
|
35
|
+
|
|
36
|
+
check_source_connection
|
|
37
|
+
check_target_connection
|
|
38
|
+
check_wal_level
|
|
39
|
+
check_replication_slots
|
|
40
|
+
check_replication_objects
|
|
41
|
+
|
|
42
|
+
(@config.migrate&.tables || []).each { |t| check_table(t) }
|
|
43
|
+
|
|
44
|
+
Result.new(
|
|
45
|
+
passed: @items.none? { |i| i.status == :fail },
|
|
46
|
+
items: @items,
|
|
47
|
+
ddl_map: @ddl_map,
|
|
48
|
+
row_counts: @row_counts
|
|
49
|
+
)
|
|
50
|
+
ensure
|
|
51
|
+
@source_pool&.close
|
|
52
|
+
@target_pool&.close
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
# ── connection & server checks ──────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
def check_source_connection
|
|
60
|
+
@source_pool.exec("SELECT 1")
|
|
61
|
+
@source_ok = true
|
|
62
|
+
pass("source connection",
|
|
63
|
+
"#{@config.source.host}:#{@config.source.port}/#{@config.source.database}")
|
|
64
|
+
rescue Connection::Error => e
|
|
65
|
+
@source_ok = false
|
|
66
|
+
fail!("source connection", e.message)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_target_connection
|
|
70
|
+
return info("target connection", "not configured — skipping") unless @target_pool
|
|
71
|
+
|
|
72
|
+
@target_pool.exec("SELECT 1")
|
|
73
|
+
@target_ok = true
|
|
74
|
+
pass("target connection",
|
|
75
|
+
"#{@config.target.host}:#{@config.target.port}/#{@config.target.database}")
|
|
76
|
+
rescue Connection::Error => e
|
|
77
|
+
@target_ok = false
|
|
78
|
+
fail!("target connection", e.message)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def check_wal_level
|
|
82
|
+
return skip("wal_level", "source not reachable") unless @source_ok
|
|
83
|
+
|
|
84
|
+
result = @source_pool.exec("SELECT setting FROM pg_settings WHERE name = 'wal_level'")
|
|
85
|
+
level = result[0]["setting"]
|
|
86
|
+
|
|
87
|
+
if level == "logical"
|
|
88
|
+
pass("wal_level", "logical")
|
|
89
|
+
else
|
|
90
|
+
fail!("wal_level",
|
|
91
|
+
"#{level.inspect} — must be 'logical'; " \
|
|
92
|
+
"set wal_level = logical in postgresql.conf and restart PostgreSQL")
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def check_replication_slots
|
|
97
|
+
return skip("replication slots", "source not reachable") unless @source_ok
|
|
98
|
+
|
|
99
|
+
max_row = @source_pool.exec("SELECT setting::int FROM pg_settings WHERE name = 'max_replication_slots'")
|
|
100
|
+
used_row = @source_pool.exec("SELECT count(*)::int FROM pg_replication_slots")
|
|
101
|
+
max_slots = max_row[0]["setting"].to_i
|
|
102
|
+
used = used_row[0]["count"].to_i
|
|
103
|
+
free = max_slots - used
|
|
104
|
+
# pcrd creates one logical slot per migration covering all tables (via a
|
|
105
|
+
# single publication), not one slot per table.
|
|
106
|
+
needed = 1
|
|
107
|
+
|
|
108
|
+
if free >= needed
|
|
109
|
+
pass("replication slots", "#{used} used / #{max_slots} max (#{free} free, #{needed} needed)")
|
|
110
|
+
else
|
|
111
|
+
fail!("replication slots",
|
|
112
|
+
"#{used} used / #{max_slots} max — need #{needed} free; " \
|
|
113
|
+
"increase max_replication_slots in postgresql.conf")
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Checks whether the slot/publication already exist, interpreted against
|
|
118
|
+
# --resume: a fresh run must not collide with leftovers, and a resume must
|
|
119
|
+
# have something to resume. This catches the conflict before any work.
|
|
120
|
+
def check_replication_objects
|
|
121
|
+
return unless @source_ok
|
|
122
|
+
return unless @config.migrate
|
|
123
|
+
|
|
124
|
+
slot = @config.migrate.replication_slot
|
|
125
|
+
resume = @options[:resume]
|
|
126
|
+
present = @source_pool.exec(
|
|
127
|
+
"SELECT 1 FROM pg_replication_slots WHERE slot_name = $1", [slot]
|
|
128
|
+
).ntuples.positive?
|
|
129
|
+
|
|
130
|
+
if resume && !present
|
|
131
|
+
fail!("replication slot state",
|
|
132
|
+
"--resume given but slot '#{slot}' does not exist; " \
|
|
133
|
+
"run a fresh migration without --resume")
|
|
134
|
+
elsif resume
|
|
135
|
+
pass("replication slot state", "slot '#{slot}' present — will resume")
|
|
136
|
+
elsif present
|
|
137
|
+
fail!("replication slot state",
|
|
138
|
+
"slot '#{slot}' already exists from a previous run; resume it with --resume, " \
|
|
139
|
+
"or remove it with `pcrd cleanup` to start over")
|
|
140
|
+
else
|
|
141
|
+
pass("replication slot state", "no existing slot — will be created")
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# ── per-table checks ────────────────────────────────────────────────────
|
|
146
|
+
|
|
147
|
+
def check_table(table_config)
|
|
148
|
+
return unless @source_ok
|
|
149
|
+
|
|
150
|
+
name = table_config.name
|
|
151
|
+
reader = Schema::Reader.new(@source_pool)
|
|
152
|
+
|
|
153
|
+
# 1. Source table exists
|
|
154
|
+
unless reader.table_exists?(name)
|
|
155
|
+
fail!("#{name}: source table", "table '#{name}' not found on source")
|
|
156
|
+
return
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
source_cols = reader.read(name)
|
|
160
|
+
row_count = reader.estimated_row_count(name)
|
|
161
|
+
pk_cols = reader.primary_key_columns(name)
|
|
162
|
+
@row_counts[name] = row_count
|
|
163
|
+
|
|
164
|
+
pass("#{name}: source table", "#{format_count(row_count)} rows")
|
|
165
|
+
|
|
166
|
+
# 2. Primary key required
|
|
167
|
+
if pk_cols.empty?
|
|
168
|
+
fail!("#{name}: primary key",
|
|
169
|
+
"no primary key found — pcrd requires a primary key for upsert " \
|
|
170
|
+
"semantics during the backfill/streaming overlap window")
|
|
171
|
+
else
|
|
172
|
+
pass("#{name}: primary key", pk_cols.join(", "))
|
|
173
|
+
check_pk_not_dropped(name, table_config, pk_cols)
|
|
174
|
+
check_replica_identity(name)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# 3. Target table must not exist (unless --force-overwrite)
|
|
178
|
+
check_target_table(name, table_config)
|
|
179
|
+
|
|
180
|
+
# 4. All spec columns exist on source
|
|
181
|
+
check_spec_columns_exist(name, table_config, source_cols)
|
|
182
|
+
|
|
183
|
+
# 5. All type casts are known + run data validation
|
|
184
|
+
check_type_casts(name, table_config, source_cols)
|
|
185
|
+
|
|
186
|
+
# 6. Generate DDL for display
|
|
187
|
+
@ddl_map[name] = Schema::DDL.generate(
|
|
188
|
+
source_columns: source_cols,
|
|
189
|
+
table_config: table_config,
|
|
190
|
+
primary_key_columns: pk_cols,
|
|
191
|
+
schema_name: "public"
|
|
192
|
+
)
|
|
193
|
+
rescue Connection::Error => e
|
|
194
|
+
fail!("#{name}", "database error: #{e.message}")
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# A primary-key column cannot be dropped: the apply engine matches replayed
|
|
198
|
+
# UPDATE/DELETE events by primary key, so dropping one breaks streaming.
|
|
199
|
+
def check_pk_not_dropped(name, table_config, pk_cols)
|
|
200
|
+
dropped = pk_cols.select do |col|
|
|
201
|
+
spec = table_config.columns&.[](col) || table_config.columns&.[](col.to_sym)
|
|
202
|
+
spec&.drop
|
|
203
|
+
end
|
|
204
|
+
return if dropped.empty?
|
|
205
|
+
|
|
206
|
+
fail!("#{name}: primary key",
|
|
207
|
+
"primary key column(s) cannot be dropped: #{dropped.join(', ')} — " \
|
|
208
|
+
"they are required to match replicated updates and deletes")
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# The source table's replica identity must carry key columns on UPDATE/DELETE
|
|
212
|
+
# or the apply engine cannot locate the target row to update/delete.
|
|
213
|
+
def check_replica_identity(name)
|
|
214
|
+
ident = Schema::Reader.new(@source_pool).replica_identity(name)
|
|
215
|
+
|
|
216
|
+
case ident
|
|
217
|
+
when "d" then pass("#{name}: replica identity", "default (primary key)")
|
|
218
|
+
when "f" then pass("#{name}: replica identity", "full")
|
|
219
|
+
when "i" then pass("#{name}: replica identity", "index")
|
|
220
|
+
when "n"
|
|
221
|
+
fail!("#{name}: replica identity",
|
|
222
|
+
"REPLICA IDENTITY NOTHING — UPDATE/DELETE WAL records carry no key " \
|
|
223
|
+
"columns, so replicated updates and deletes cannot be applied. " \
|
|
224
|
+
"Run: ALTER TABLE #{Sql.quote_table(name)} REPLICA IDENTITY DEFAULT (or FULL).")
|
|
225
|
+
else
|
|
226
|
+
warn("#{name}: replica identity", "unrecognized setting #{ident.inspect}")
|
|
227
|
+
end
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def check_target_table(name, _table_config)
|
|
231
|
+
return unless @target_ok
|
|
232
|
+
|
|
233
|
+
reader = Schema::Reader.new(@target_pool)
|
|
234
|
+
if reader.table_exists?(name)
|
|
235
|
+
if @options[:"force-overwrite"]
|
|
236
|
+
warn("#{name}: target table", "already exists — will be dropped and recreated (--force-overwrite)")
|
|
237
|
+
else
|
|
238
|
+
fail!("#{name}: target table",
|
|
239
|
+
"table '#{name}' already exists on target; " \
|
|
240
|
+
"pass --force-overwrite to drop and recreate it")
|
|
241
|
+
end
|
|
242
|
+
else
|
|
243
|
+
pass("#{name}: target table", "does not exist — will be created")
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
def check_spec_columns_exist(name, table_config, source_cols)
|
|
248
|
+
source_names = source_cols.map(&:name)
|
|
249
|
+
missing = (table_config.columns || {}).keys.map(&:to_s) - source_names
|
|
250
|
+
if missing.any?
|
|
251
|
+
fail!("#{name}: column spec",
|
|
252
|
+
"column(s) in spec not found on source: #{missing.join(', ')}")
|
|
253
|
+
else
|
|
254
|
+
pass("#{name}: column spec", "all spec columns found on source")
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def check_type_casts(name, table_config, source_cols)
|
|
259
|
+
col_index = source_cols.each_with_object({}) { |c, h| h[c.name] = c }
|
|
260
|
+
unknown = []
|
|
261
|
+
safe_changes = []
|
|
262
|
+
validator = Transform::Validator.new(@source_pool)
|
|
263
|
+
failures = validator.validate(table_config, source_cols)
|
|
264
|
+
|
|
265
|
+
(table_config.columns || {}).each do |src_name, col_spec|
|
|
266
|
+
next if col_spec.drop || col_spec.type.nil?
|
|
267
|
+
|
|
268
|
+
src_col = col_index[src_name.to_s]
|
|
269
|
+
next unless src_col
|
|
270
|
+
|
|
271
|
+
safety = Transform::TypeMap.cast_safety(src_col.type_name, col_spec.type)
|
|
272
|
+
|
|
273
|
+
if safety == :unsupported
|
|
274
|
+
unknown << "#{src_name}: #{src_col.display_type} → #{col_spec.type}"
|
|
275
|
+
else
|
|
276
|
+
label = [col_spec.rename ? "rename" : nil,
|
|
277
|
+
col_spec.type ? "#{src_col.display_type} → #{col_spec.type}" : nil,
|
|
278
|
+
"(#{safety.to_s.tr('_', ' ')})"].compact.join(" ")
|
|
279
|
+
safe_changes << "#{src_name}: #{label}"
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
if unknown.any?
|
|
284
|
+
fail!("#{name}: type casts",
|
|
285
|
+
"unsupported type transition(s):\n" +
|
|
286
|
+
unknown.map { " #{_1}" }.join("\n"))
|
|
287
|
+
return
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
if safe_changes.any?
|
|
291
|
+
pass("#{name}: type casts", safe_changes.join("\n" + " " * 6))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
hard_fails = failures.reject(&:warn_only)
|
|
295
|
+
warnings = failures.select(&:warn_only)
|
|
296
|
+
|
|
297
|
+
if hard_fails.any?
|
|
298
|
+
msgs = hard_fails.map do |f|
|
|
299
|
+
"#{f.column_name} (#{f.source_type} → #{f.target_type}): " \
|
|
300
|
+
"#{f.failing_count} row(s) would fail — #{f.description}"
|
|
301
|
+
end
|
|
302
|
+
fail!("#{name}: data validation",
|
|
303
|
+
"#{hard_fails.length} cast(s) failed validation:\n" +
|
|
304
|
+
msgs.map { " #{_1}" }.join("\n"))
|
|
305
|
+
else
|
|
306
|
+
pass("#{name}: data validation", "all casts validated")
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
warnings.each do |w|
|
|
310
|
+
warn("#{name}: #{w.column_name}",
|
|
311
|
+
"#{w.source_type} → #{w.target_type}: #{w.description}")
|
|
312
|
+
end
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# ── helpers ─────────────────────────────────────────────────────────────
|
|
316
|
+
|
|
317
|
+
def open_pool(conn_config)
|
|
318
|
+
Connection::Client.new(conn_config)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def pass(label, detail = nil) @items << Item.new(status: :pass, label: label, detail: detail) end
|
|
322
|
+
def fail!(label, detail) @items << Item.new(status: :fail, label: label, detail: detail) end
|
|
323
|
+
def warn(label, detail) @items << Item.new(status: :warn, label: label, detail: detail) end
|
|
324
|
+
def info(label, detail = nil) @items << Item.new(status: :info, label: label, detail: detail) end
|
|
325
|
+
def skip(label, reason) @items << Item.new(status: :info, label: label, detail: "skipped — #{reason}") end
|
|
326
|
+
|
|
327
|
+
def format_count(n)
|
|
328
|
+
n.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
329
|
+
end
|
|
330
|
+
end
|
|
331
|
+
end
|