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,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "set"
|
|
4
|
+
|
|
5
|
+
module Pcrd
|
|
6
|
+
module Readiness
|
|
7
|
+
# Builds the target-readiness manifest: for each migrated table, which
|
|
8
|
+
# secondary objects exist on the source, whether the target already has
|
|
9
|
+
# them, and runnable DDL to create the missing ones before cutover.
|
|
10
|
+
#
|
|
11
|
+
# The load DDL (Schema::DDL) creates only table + primary key; indexes,
|
|
12
|
+
# constraints, grants, etc. are deferred so the bulk load is fast. This
|
|
13
|
+
# turns that deferral from tribal knowledge into an explicit checklist.
|
|
14
|
+
#
|
|
15
|
+
# Rename/drop aware: an object referencing a dropped column is reported as
|
|
16
|
+
# not-recreatable; one referencing a renamed column is flagged for manual
|
|
17
|
+
# regeneration (its source DDL is shown commented out) rather than emitting
|
|
18
|
+
# silently-wrong SQL.
|
|
19
|
+
#
|
|
20
|
+
# Sequences/identity are reported as informational — they are restored
|
|
21
|
+
# automatically by `pcrd cutover` (Cutover::Sequences), so the manifest does
|
|
22
|
+
# not emit competing DDL for them.
|
|
23
|
+
class Manifest
|
|
24
|
+
# status: :missing (DDL provided) | :present | :needs_review | :info
|
|
25
|
+
Entry = Data.define(:category, :name, :status, :detail, :ddl)
|
|
26
|
+
Table = Data.define(:table_name, :entries)
|
|
27
|
+
Result = Data.define(:tables)
|
|
28
|
+
|
|
29
|
+
KIND_LABEL = { "f" => "foreign key", "u" => "unique constraint", "c" => "check constraint" }.freeze
|
|
30
|
+
|
|
31
|
+
def initialize(source_pool:, target_pool:, config:)
|
|
32
|
+
@source = source_pool
|
|
33
|
+
@target = target_pool
|
|
34
|
+
@config = config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def build
|
|
38
|
+
src = Schema::ObjectReader.new(@source)
|
|
39
|
+
tgt = Schema::ObjectReader.new(@target)
|
|
40
|
+
|
|
41
|
+
tables = (@config.migrate&.tables || []).map do |table_config|
|
|
42
|
+
Table.new(table_name: table_config.name, entries: entries_for(table_config, src, tgt))
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Result.new(tables: tables)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def entries_for(table_config, src, tgt)
|
|
51
|
+
name = table_config.name
|
|
52
|
+
drops, renames = change_maps(table_config)
|
|
53
|
+
present_idx = tgt.indexes(name).map(&:name).to_set
|
|
54
|
+
present_con = tgt.constraints(name).map(&:name).to_set
|
|
55
|
+
|
|
56
|
+
index_entries(name, src.indexes(name), present_idx, drops, renames) +
|
|
57
|
+
constraint_entries(name, src.constraints(name), present_con, drops, renames) +
|
|
58
|
+
sequence_entries(src.identity_columns(name)) +
|
|
59
|
+
owner_entries(name, src, tgt) +
|
|
60
|
+
grant_entries(name, src, tgt) +
|
|
61
|
+
comment_entries(name, src, tgt, drops, renames)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def index_entries(table, indexes, present, drops, renames)
|
|
65
|
+
indexes.map do |ix|
|
|
66
|
+
review = review_reason(ix.columns, drops, renames)
|
|
67
|
+
if present.include?(ix.name)
|
|
68
|
+
entry("index", ix.name, :present, "already on target", nil)
|
|
69
|
+
elsif review
|
|
70
|
+
entry("index", ix.name, :needs_review, review, "-- #{review}\n-- #{ix.definition};")
|
|
71
|
+
else
|
|
72
|
+
entry("index", ix.name, :missing, ix.unique ? "unique index" : "index",
|
|
73
|
+
"#{concurrently(ix.definition)};")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def constraint_entries(table, constraints, present, drops, renames)
|
|
79
|
+
constraints.map do |c|
|
|
80
|
+
label = KIND_LABEL[c.kind]
|
|
81
|
+
review = review_reason(c.columns, drops, renames)
|
|
82
|
+
if present.include?(c.name)
|
|
83
|
+
entry("constraint", c.name, :present, "already on target", nil)
|
|
84
|
+
elsif review
|
|
85
|
+
entry("constraint", c.name, :needs_review, review,
|
|
86
|
+
"-- #{review}\n-- ALTER TABLE #{Sql.quote_table(table)} " \
|
|
87
|
+
"ADD CONSTRAINT #{Sql.quote_ident(c.name)} #{c.definition};")
|
|
88
|
+
else
|
|
89
|
+
fk_note = c.kind == "f" ? " -- run after all referenced tables are loaded" : ""
|
|
90
|
+
ddl = "ALTER TABLE #{Sql.quote_table(table)} " \
|
|
91
|
+
"ADD CONSTRAINT #{Sql.quote_ident(c.name)} #{c.definition};#{fk_note}"
|
|
92
|
+
entry("constraint", c.name, :missing, label, ddl)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def sequence_entries(identity_columns)
|
|
98
|
+
identity_columns.map do |col|
|
|
99
|
+
entry("sequence", col.column, :info,
|
|
100
|
+
"#{col.kind} column — restored automatically by `pcrd cutover`", nil)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def owner_entries(table, src, tgt)
|
|
105
|
+
source_owner = src.owner(table)
|
|
106
|
+
return [] unless source_owner
|
|
107
|
+
|
|
108
|
+
if source_owner == tgt.owner(table)
|
|
109
|
+
[entry("owner", source_owner, :present, "owner already #{source_owner}", nil)]
|
|
110
|
+
else
|
|
111
|
+
[entry("owner", source_owner, :missing, "set owner to #{source_owner}",
|
|
112
|
+
"ALTER TABLE #{Sql.quote_table(table)} OWNER TO #{Sql.quote_ident(source_owner)};")]
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def grant_entries(table, src, tgt)
|
|
117
|
+
target = tgt.grants(table).to_h { |g| [g.grantee, g.privileges] }
|
|
118
|
+
|
|
119
|
+
src.grants(table).map do |g|
|
|
120
|
+
have = target[g.grantee] || []
|
|
121
|
+
if (g.privileges - have).empty?
|
|
122
|
+
entry("grant", g.grantee, :present, g.privileges.join(", "), nil)
|
|
123
|
+
else
|
|
124
|
+
grantee_sql = g.grantee == "PUBLIC" ? "PUBLIC" : Sql.quote_ident(g.grantee)
|
|
125
|
+
entry("grant", g.grantee, :missing, g.privileges.join(", "),
|
|
126
|
+
"GRANT #{g.privileges.join(', ')} ON #{Sql.quote_table(table)} TO #{grantee_sql};")
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Comments are rename-safe: only the column identifier changes, so a
|
|
132
|
+
# renamed column's comment is re-emitted against its target name; dropped
|
|
133
|
+
# columns are skipped.
|
|
134
|
+
def comment_entries(table, src, tgt, drops, renames)
|
|
135
|
+
entries = []
|
|
136
|
+
|
|
137
|
+
source_table_comment = src.table_comment(table)
|
|
138
|
+
if source_table_comment
|
|
139
|
+
status = source_table_comment == tgt.table_comment(table) ? :present : :missing
|
|
140
|
+
ddl = status == :missing ? "COMMENT ON TABLE #{Sql.quote_table(table)} IS #{quote_literal(source_table_comment)};" : nil
|
|
141
|
+
entries << entry("comment", "(table)", status, truncate(source_table_comment), ddl)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
target_comments = tgt.column_comments(table)
|
|
145
|
+
src.column_comments(table).each do |col, comment|
|
|
146
|
+
next if drops.include?(col)
|
|
147
|
+
|
|
148
|
+
target_col = renames[col] || col
|
|
149
|
+
if target_comments[target_col] == comment
|
|
150
|
+
entries << entry("comment", target_col, :present, truncate(comment), nil)
|
|
151
|
+
else
|
|
152
|
+
entries << entry("comment", target_col, :missing, truncate(comment),
|
|
153
|
+
"COMMENT ON COLUMN #{Sql.quote_table(table)}.#{Sql.quote_ident(target_col)} " \
|
|
154
|
+
"IS #{quote_literal(comment)};")
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
entries
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def quote_literal(str)
|
|
162
|
+
"'#{str.gsub("'", "''")}'"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def truncate(str)
|
|
166
|
+
str.length > 40 ? "#{str[0, 37]}..." : str
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Injects CONCURRENTLY so the index build does not lock out writes.
|
|
170
|
+
def concurrently(definition)
|
|
171
|
+
definition.sub(/\ACREATE (UNIQUE )?INDEX /, 'CREATE \1INDEX CONCURRENTLY ')
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def review_reason(columns, drops, renames)
|
|
175
|
+
dropped = columns & drops
|
|
176
|
+
return "references dropped column(s): #{dropped.join(', ')} — not recreated" if dropped.any?
|
|
177
|
+
|
|
178
|
+
renamed = columns & renames.keys
|
|
179
|
+
if renamed.any?
|
|
180
|
+
pairs = renamed.map { |c| "#{c}->#{renames[c]}" }.join(", ")
|
|
181
|
+
return "references renamed column(s): #{pairs} — regenerate manually"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def change_maps(table_config)
|
|
188
|
+
cols = table_config.columns || {}
|
|
189
|
+
drops = cols.select { |_, spec| spec.drop }.keys.map(&:to_s)
|
|
190
|
+
renames = cols.each_with_object({}) do |(src, spec), h|
|
|
191
|
+
h[src.to_s] = spec.rename if spec.rename
|
|
192
|
+
end
|
|
193
|
+
[drops, renames]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def entry(category, name, status, detail, ddl)
|
|
197
|
+
Entry.new(category: category, name: name, status: status, detail: detail, ddl: ddl)
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pcrd
|
|
4
|
+
module Replication
|
|
5
|
+
# Streams WAL messages from a pgoutput logical replication slot and
|
|
6
|
+
# buffers complete transactions onto a Thread::Queue for the apply engine.
|
|
7
|
+
#
|
|
8
|
+
# Protocol (inside each raw message from the server):
|
|
9
|
+
# 0x77 ('w') — XLogData: 1 type + 8 wal_start + 8 wal_end + 8 ts + payload
|
|
10
|
+
# 0x6B ('k') — Primary keepalive: 1 type + 8 wal_end + 8 ts + 1 reply_flag
|
|
11
|
+
#
|
|
12
|
+
# The consumer must respond to keepalives with a StandbyStatusUpdate ('r')
|
|
13
|
+
# within wal_sender_timeout (default 60s) or the server drops the connection.
|
|
14
|
+
#
|
|
15
|
+
# Thread model:
|
|
16
|
+
# start — launches a background thread that drives the stream loop
|
|
17
|
+
# stop — signals the thread to exit cleanly after the current poll
|
|
18
|
+
# queue — Thread::Queue; pop from the apply engine side
|
|
19
|
+
# advance_lsn — call from apply engine after each applied transaction
|
|
20
|
+
class Consumer
|
|
21
|
+
# A complete buffered transaction ready for the apply engine.
|
|
22
|
+
Transaction = Data.define(:begin_msg, :events, :commit_lsn)
|
|
23
|
+
|
|
24
|
+
XLOG_DATA = 0x77
|
|
25
|
+
KEEPALIVE = 0x6B
|
|
26
|
+
PG_EPOCH_OFFSET_US = 946_684_800 * 1_000_000
|
|
27
|
+
KEEPALIVE_INTERVAL = 10 # seconds between proactive keepalives to server
|
|
28
|
+
WAIT_TIMEOUT = 1 # max seconds per wait_readable; limits stop latency
|
|
29
|
+
|
|
30
|
+
# Backpressure cap. The queue holds at most this many buffered
|
|
31
|
+
# transactions; once full, the stream loop stops reading new WAL (the
|
|
32
|
+
# server's flow control kicks in) until the apply side drains it. This
|
|
33
|
+
# bounds memory during a long backfill instead of letting the queue grow
|
|
34
|
+
# without limit. Tuned via :max_queue.
|
|
35
|
+
DEFAULT_MAX_QUEUE = 10_000
|
|
36
|
+
FULL_QUEUE_BACKOFF = 0.05 # seconds to wait between retries when full
|
|
37
|
+
|
|
38
|
+
def initialize(repl_conn:, parser:, slot_name:, pub_name:, start_lsn: "0/0",
|
|
39
|
+
max_queue: DEFAULT_MAX_QUEUE)
|
|
40
|
+
@repl = repl_conn
|
|
41
|
+
@parser = parser
|
|
42
|
+
@slot_name = slot_name
|
|
43
|
+
@pub_name = pub_name
|
|
44
|
+
@start_lsn = start_lsn
|
|
45
|
+
@queue = SizedQueue.new(max_queue)
|
|
46
|
+
@stop = false
|
|
47
|
+
@mutex = Mutex.new
|
|
48
|
+
@conf_lsn = 0 # last applied LSN (int64); advanced by apply engine
|
|
49
|
+
@last_received_lsn = nil # commit LSN of the most recently buffered txn
|
|
50
|
+
@thread = nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
attr_reader :queue, :parser, :last_error
|
|
54
|
+
|
|
55
|
+
# Commit LSN of the most recent transaction buffered onto the queue, for
|
|
56
|
+
# observability ("how far has streaming read?"). nil until the first txn.
|
|
57
|
+
def last_received_lsn
|
|
58
|
+
@mutex.synchronize { @last_received_lsn }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# Number of buffered transactions waiting to be applied (backpressure gauge).
|
|
62
|
+
def queue_depth
|
|
63
|
+
@queue.size
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Opens the replication connection and starts the background thread.
|
|
67
|
+
def start
|
|
68
|
+
@repl.open
|
|
69
|
+
@repl.start_replication(slot_name: @slot_name, pub_name: @pub_name, start_lsn: @start_lsn)
|
|
70
|
+
@thread = Thread.new { stream_loop }
|
|
71
|
+
self
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Signals the consumer to stop after the current poll cycle.
|
|
75
|
+
def stop
|
|
76
|
+
@mutex.synchronize { @stop = true }
|
|
77
|
+
@thread&.join(5)
|
|
78
|
+
@repl.close
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def stopped?
|
|
82
|
+
@mutex.synchronize { @stop }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# True if the streaming thread exited because of an error. The apply
|
|
86
|
+
# side polls this when the queue drains empty so a dead consumer
|
|
87
|
+
# surfaces as a failure instead of looking like "caught up and idle".
|
|
88
|
+
def failed?
|
|
89
|
+
@mutex.synchronize { !@last_error.nil? }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Called by the apply engine after a transaction has been applied.
|
|
93
|
+
# Updates the LSN we report back to the server (WAL reclaim point).
|
|
94
|
+
def advance_lsn(lsn_string)
|
|
95
|
+
int = lsn_to_int(lsn_string)
|
|
96
|
+
@mutex.synchronize { @conf_lsn = [@conf_lsn, int].max }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def stream_loop
|
|
102
|
+
@current_begin = nil
|
|
103
|
+
@current_events = []
|
|
104
|
+
last_keepalive = monotonic
|
|
105
|
+
|
|
106
|
+
loop do
|
|
107
|
+
# Short wait so stop! is noticed within WAIT_TIMEOUT seconds.
|
|
108
|
+
break if stopped?
|
|
109
|
+
|
|
110
|
+
if @repl.wait_readable(WAIT_TIMEOUT)
|
|
111
|
+
# Drain all messages available right now in one pass.
|
|
112
|
+
loop do
|
|
113
|
+
raw = @repl.get_copy_data
|
|
114
|
+
break if raw.nil? || raw == false
|
|
115
|
+
dispatch(raw)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Send proactive keepalive if nothing has been received recently.
|
|
120
|
+
if monotonic - last_keepalive >= KEEPALIVE_INTERVAL
|
|
121
|
+
send_status
|
|
122
|
+
last_keepalive = monotonic
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
rescue => e
|
|
126
|
+
# Record the failure and let the thread exit. We deliberately do NOT
|
|
127
|
+
# enqueue anything: a malformed transaction here would be applied as a
|
|
128
|
+
# no-op and its sentinel "LSN" checkpointed, hiding the failure. The
|
|
129
|
+
# apply loop detects the dead consumer via #failed? once the queue
|
|
130
|
+
# drains and raises Replication::Error.
|
|
131
|
+
@mutex.synchronize { @last_error = e }
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def dispatch(raw)
|
|
135
|
+
tag = raw.getbyte(0)
|
|
136
|
+
|
|
137
|
+
case tag
|
|
138
|
+
when XLOG_DATA
|
|
139
|
+
# Header: 1 tag + 8 wal_start + 8 wal_end + 8 ts = 25 bytes
|
|
140
|
+
payload = raw.b[25..]
|
|
141
|
+
handle_pgoutput(@parser.parse(payload))
|
|
142
|
+
|
|
143
|
+
when KEEPALIVE
|
|
144
|
+
# Bytes 1-8: wal_end; byte 17: reply_requested
|
|
145
|
+
reply_needed = raw.getbyte(17) == 1
|
|
146
|
+
send_status if reply_needed
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def handle_pgoutput(msg)
|
|
151
|
+
case msg
|
|
152
|
+
when Pgoutput::Messages::Begin
|
|
153
|
+
@current_begin = msg
|
|
154
|
+
@current_events = []
|
|
155
|
+
|
|
156
|
+
when Pgoutput::Messages::Insert,
|
|
157
|
+
Pgoutput::Messages::Update,
|
|
158
|
+
Pgoutput::Messages::Delete
|
|
159
|
+
@current_events << msg
|
|
160
|
+
|
|
161
|
+
when Pgoutput::Messages::Commit
|
|
162
|
+
unless @current_events.empty?
|
|
163
|
+
enqueue(Transaction.new(
|
|
164
|
+
begin_msg: @current_begin,
|
|
165
|
+
events: @current_events.dup,
|
|
166
|
+
commit_lsn: msg.lsn
|
|
167
|
+
))
|
|
168
|
+
end
|
|
169
|
+
@current_begin = nil
|
|
170
|
+
@current_events = []
|
|
171
|
+
|
|
172
|
+
when Pgoutput::Messages::Truncate
|
|
173
|
+
reject_truncate(msg)
|
|
174
|
+
|
|
175
|
+
# Relation and Type are cached by the parser; no action needed here.
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# pcrd does not replicate TRUNCATE: silently ignoring it would leave the
|
|
180
|
+
# target diverged from the source with no signal. Halt loudly so the
|
|
181
|
+
# operator can truncate the target deliberately and resume. The
|
|
182
|
+
# publication only covers migrated tables, so any TRUNCATE is relevant.
|
|
183
|
+
def reject_truncate(msg)
|
|
184
|
+
names = msg.relation_ids.map do |id|
|
|
185
|
+
(r = @parser.relation(id)) ? "#{r.namespace}.#{r.name}" : "oid:#{id}"
|
|
186
|
+
end
|
|
187
|
+
raise Error,
|
|
188
|
+
"TRUNCATE received for #{names.join(", ")}. pcrd does not replicate " \
|
|
189
|
+
"TRUNCATE; the target would silently diverge. Migration halted — " \
|
|
190
|
+
"truncate the target manually if intended, then resume."
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Pushes a transaction onto the bounded queue. If the queue is full the
|
|
194
|
+
# apply side is behind, so we wait — but keep answering the server with
|
|
195
|
+
# keepalives so it does not drop us past wal_sender_timeout while we
|
|
196
|
+
# apply backpressure. Returns early if stop is requested.
|
|
197
|
+
def enqueue(txn)
|
|
198
|
+
loop do
|
|
199
|
+
return if stopped?
|
|
200
|
+
|
|
201
|
+
begin
|
|
202
|
+
@queue.push(txn, true) # non-blocking; raises ThreadError when full
|
|
203
|
+
@mutex.synchronize { @last_received_lsn = txn.commit_lsn }
|
|
204
|
+
return
|
|
205
|
+
rescue ThreadError
|
|
206
|
+
send_status
|
|
207
|
+
sleep FULL_QUEUE_BACKOFF
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Sends a StandbyStatusUpdate to keep the connection alive and
|
|
213
|
+
# tell the server which LSN we have confirmed applying.
|
|
214
|
+
# 'r' tag + write_lsn (8) + flush_lsn (8) + apply_lsn (8) + ts (8) + reply (1)
|
|
215
|
+
def send_status
|
|
216
|
+
lsn = @mutex.synchronize { @conf_lsn }
|
|
217
|
+
now = ((Time.now.to_f * 1_000_000).to_i - PG_EPOCH_OFFSET_US)
|
|
218
|
+
msg = "r".b + [lsn, lsn, lsn, now, 0].pack("Q>Q>Q>q>C")
|
|
219
|
+
@repl.put_copy_data(msg)
|
|
220
|
+
rescue Connection::Error
|
|
221
|
+
nil # connection may be closing; ignore
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def lsn_to_int(str)
|
|
225
|
+
return 0 unless str&.include?("/")
|
|
226
|
+
high, low = str.split("/").map { _1.to_i(16) }
|
|
227
|
+
(high << 32) | low
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def monotonic
|
|
231
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pcrd
|
|
4
|
+
module Replication
|
|
5
|
+
# Raised when the WAL streaming consumer stops unexpectedly (e.g. the
|
|
6
|
+
# replication connection drops or pgoutput parsing fails). Carries the
|
|
7
|
+
# original error as #cause when available.
|
|
8
|
+
class Error < Pcrd::Error; end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pcrd
|
|
4
|
+
module Replication
|
|
5
|
+
module Pgoutput
|
|
6
|
+
# Immutable structs for each pgoutput message type.
|
|
7
|
+
# Produced by Parser#parse; consumed by the WAL consumer (Phase 9).
|
|
8
|
+
#
|
|
9
|
+
# Tuple data (new_tuple / old_tuple) is a Hash<column_name, value> where:
|
|
10
|
+
# value = String — column value in text format
|
|
11
|
+
# value = nil — SQL NULL
|
|
12
|
+
# value = :toast — unchanged TOASTed value (not re-sent by server)
|
|
13
|
+
module Messages
|
|
14
|
+
# One column description inside a Relation message.
|
|
15
|
+
RelationColumn = Data.define(
|
|
16
|
+
:flags, # Integer: bit 0 set = part of replica identity key
|
|
17
|
+
:name, # String: column name
|
|
18
|
+
:type_id, # Integer: OID of the column data type
|
|
19
|
+
:type_modifier # Integer: atttypmod (-1 means no modifier)
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
# B — transaction begin
|
|
23
|
+
# lsn: String "X/Y" — final LSN of the transaction
|
|
24
|
+
# commit_time: Time (UTC)
|
|
25
|
+
# xid: Integer — transaction ID
|
|
26
|
+
Begin = Data.define(:lsn, :commit_time, :xid)
|
|
27
|
+
|
|
28
|
+
# C — transaction commit
|
|
29
|
+
# lsn: String "X/Y" — commit LSN
|
|
30
|
+
# end_lsn: String "X/Y" — LSN after the end of the transaction record
|
|
31
|
+
# commit_time: Time (UTC)
|
|
32
|
+
Commit = Data.define(:flags, :lsn, :end_lsn, :commit_time)
|
|
33
|
+
|
|
34
|
+
# R — relation (table schema snapshot)
|
|
35
|
+
# id: Integer — relation OID
|
|
36
|
+
# namespace: String — schema name (empty for pg_catalog)
|
|
37
|
+
# name: String — table name
|
|
38
|
+
# replica_identity: String — one of 'd', 'n', 'f', 'i'
|
|
39
|
+
# columns: Array<RelationColumn>
|
|
40
|
+
Relation = Data.define(:id, :namespace, :name, :replica_identity, :columns)
|
|
41
|
+
|
|
42
|
+
# T — data type
|
|
43
|
+
Type = Data.define(:id, :namespace, :name)
|
|
44
|
+
|
|
45
|
+
# I — INSERT
|
|
46
|
+
Insert = Data.define(:relation_id, :new_tuple)
|
|
47
|
+
|
|
48
|
+
# U — UPDATE
|
|
49
|
+
# old_tuple is nil unless REPLICA IDENTITY is FULL or INDEX
|
|
50
|
+
Update = Data.define(:relation_id, :old_tuple, :new_tuple)
|
|
51
|
+
|
|
52
|
+
# D — DELETE
|
|
53
|
+
# old_tuple contains either the key columns or all columns depending on REPLICA IDENTITY
|
|
54
|
+
Delete = Data.define(:relation_id, :old_tuple)
|
|
55
|
+
|
|
56
|
+
# O — origin (the replication origin this transaction came from)
|
|
57
|
+
Origin = Data.define(:lsn, :name)
|
|
58
|
+
|
|
59
|
+
# A — TRUNCATE (PG 11+)
|
|
60
|
+
# option_bits: Integer — 1 = CASCADE, 2 = RESTART IDENTITY
|
|
61
|
+
Truncate = Data.define(:option_bits, :relation_ids)
|
|
62
|
+
|
|
63
|
+
# M — logical decoding message (PG 14+)
|
|
64
|
+
LogicalMessage = Data.define(:flags, :lsn, :prefix, :content)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|