pg-replication-protocol 0.0.1 → 0.0.3
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.rspec +1 -0
- data/Gemfile +5 -0
- data/Gemfile.lock +26 -1
- data/README.md +63 -1
- data/lib/pg/replication/buffer.rb +7 -19
- data/lib/pg/replication/pg_output.rb +32 -24
- data/lib/pg/replication/protocol.rb +1 -47
- data/lib/pg/replication/version.rb +1 -1
- data/lib/pg/replication.rb +105 -0
- metadata +4 -3
- data/lib/pg/replication/stream.rb +0 -53
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 44b3fb2ad8e9ddb30400a0cf8acaf04a70c09d9ae0641e5e475bdf1394c85d5e
|
4
|
+
data.tar.gz: fd766d8b6157af89d017f9dbff382e80278614fe005320c26d7eb89c4f3c4701
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 387313faa12bfa5084ea78920f8d860b048d57050e574793e99c9b8fd922cfb60dff8f843a68ce1ba3d05dc010aa011a3b5816a9a8c703e522411c4fff48c1e9
|
7
|
+
data.tar.gz: 5df28c6e5fe755a34cf1544607b6439f903a6da5c38f355d41c9d4b8e6e1dbd834a01f2d2ab6cc46d0c9abd20ba98d0fc47fcbbda57146394f962ee59c2f22e1
|
data/.rspec
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
--require spec_helper
|
data/Gemfile
CHANGED
data/Gemfile.lock
CHANGED
@@ -1,14 +1,37 @@
|
|
1
1
|
PATH
|
2
2
|
remote: .
|
3
3
|
specs:
|
4
|
-
pg-replication-protocol (0.0.
|
4
|
+
pg-replication-protocol (0.0.2)
|
5
5
|
pg (~> 1.0)
|
6
6
|
|
7
7
|
GEM
|
8
8
|
remote: https://rubygems.org/
|
9
9
|
specs:
|
10
|
+
diff-lcs (1.5.1)
|
11
|
+
docker-api (2.4.0)
|
12
|
+
excon (>= 0.64.0)
|
13
|
+
multi_json
|
14
|
+
excon (1.2.1)
|
15
|
+
multi_json (1.15.0)
|
10
16
|
pg (1.5.9)
|
11
17
|
rake (13.2.1)
|
18
|
+
rspec (3.13.0)
|
19
|
+
rspec-core (~> 3.13.0)
|
20
|
+
rspec-expectations (~> 3.13.0)
|
21
|
+
rspec-mocks (~> 3.13.0)
|
22
|
+
rspec-core (3.13.2)
|
23
|
+
rspec-support (~> 3.13.0)
|
24
|
+
rspec-expectations (3.13.3)
|
25
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
26
|
+
rspec-support (~> 3.13.0)
|
27
|
+
rspec-mocks (3.13.2)
|
28
|
+
diff-lcs (>= 1.2.0, < 2.0)
|
29
|
+
rspec-support (~> 3.13.0)
|
30
|
+
rspec-support (3.13.1)
|
31
|
+
testcontainers-core (0.2.0)
|
32
|
+
docker-api (~> 2.2)
|
33
|
+
testcontainers-postgres (0.2.0)
|
34
|
+
testcontainers-core (~> 0.1)
|
12
35
|
|
13
36
|
PLATFORMS
|
14
37
|
x86_64-linux
|
@@ -16,6 +39,8 @@ PLATFORMS
|
|
16
39
|
DEPENDENCIES
|
17
40
|
pg-replication-protocol!
|
18
41
|
rake (~> 13.0)
|
42
|
+
rspec (~> 3.0)
|
43
|
+
testcontainers-postgres
|
19
44
|
|
20
45
|
BUNDLED WITH
|
21
46
|
2.4.1
|
data/README.md
CHANGED
@@ -1 +1,63 @@
|
|
1
|
-
#
|
1
|
+
# PG::Replication
|
2
|
+
|
3
|
+
## Usage
|
4
|
+
|
5
|
+
Add to your Gemfile:
|
6
|
+
|
7
|
+
```ruby
|
8
|
+
gem "pg-replication-protocol", require: "pg/replication"
|
9
|
+
```
|
10
|
+
|
11
|
+
## Demo
|
12
|
+
|
13
|
+
```ruby
|
14
|
+
require "pg"
|
15
|
+
require "pg/replication"
|
16
|
+
|
17
|
+
# Important to create a connection with the `replication: "database"` flag
|
18
|
+
connection = PG.connect(..., replication: "database")
|
19
|
+
|
20
|
+
# Create a publication and a slot (in a real use case the slot will not be temporary)
|
21
|
+
connection.query("CREATE PUBLICATION some_publication FOR ALL TABLES")
|
22
|
+
connection.query('CREATE_REPLICATION_SLOT some_slot TEMPORARY LOGICAL "pgoutput"')
|
23
|
+
|
24
|
+
# Just a storage for our table relation data
|
25
|
+
tables = {}
|
26
|
+
|
27
|
+
# Start a pgoutput plugin replication slot message stream
|
28
|
+
connection.start_pgoutput_replication_slot(slot, publications).each do |msg|
|
29
|
+
case msg
|
30
|
+
in PG::Replication::PGOutput::Relation(oid:, name:, columns:)
|
31
|
+
# We receive this message on the first row of each table
|
32
|
+
tables[oid] = { name:, columns: }
|
33
|
+
|
34
|
+
in PG::Replication::PGOutput::Begin
|
35
|
+
puts "Transaction start"
|
36
|
+
|
37
|
+
in PG::Replication::PGOutput::Commit
|
38
|
+
puts "Transaction end"
|
39
|
+
|
40
|
+
in PG::Replication::PGOutput::Insert(oid:, new:)
|
41
|
+
puts "Insert #{tables[oid][:name]}"
|
42
|
+
new.zip(tables[oid][:columns]).each do |tuple, col|
|
43
|
+
puts "#{col.name}: #{tuple.data || "NULL"}"
|
44
|
+
end
|
45
|
+
|
46
|
+
in PG::Replication::PGOutput::Update(oid:, new:, old:)
|
47
|
+
puts "Update #{tables[oid][:name]}"
|
48
|
+
if !old.empty? && new != old
|
49
|
+
new.zip(old, tables[oid][:columns]).each do |new, old, col|
|
50
|
+
if new != old
|
51
|
+
puts "Changed #{col.name}: #{old.data || "NULL"} > #{new.data || "NULL"}"
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
in PG::Replication::PGOutput::Delete(oid:)
|
57
|
+
puts "Delete #{tables[oid][:name]}"
|
58
|
+
|
59
|
+
else
|
60
|
+
nil
|
61
|
+
end
|
62
|
+
end
|
63
|
+
```
|
@@ -1,19 +1,15 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "delegate"
|
2
4
|
require "stringio"
|
3
5
|
|
4
6
|
module PG
|
5
7
|
module Replication
|
6
|
-
class Buffer
|
7
|
-
attr_reader :buffer
|
8
|
-
|
8
|
+
class Buffer < SimpleDelegator
|
9
9
|
def self.from_string(str)
|
10
10
|
new(StringIO.new(str))
|
11
11
|
end
|
12
12
|
|
13
|
-
def initialize(buffer)
|
14
|
-
@buffer = buffer
|
15
|
-
end
|
16
|
-
|
17
13
|
def read_char
|
18
14
|
read_int8.chr
|
19
15
|
end
|
@@ -23,19 +19,19 @@ module PG
|
|
23
19
|
end
|
24
20
|
|
25
21
|
def read_int8
|
26
|
-
|
22
|
+
read(1).unpack("C").first
|
27
23
|
end
|
28
24
|
|
29
25
|
def read_int16
|
30
|
-
|
26
|
+
read(2).unpack("n").first
|
31
27
|
end
|
32
28
|
|
33
29
|
def read_int32
|
34
|
-
|
30
|
+
read(4).unpack("N").first
|
35
31
|
end
|
36
32
|
|
37
33
|
def read_int64
|
38
|
-
|
34
|
+
read(8).unpack("Q>").first
|
39
35
|
end
|
40
36
|
|
41
37
|
def read_timestamp
|
@@ -54,14 +50,6 @@ module PG
|
|
54
50
|
end
|
55
51
|
end
|
56
52
|
end
|
57
|
-
|
58
|
-
def read(size = nil)
|
59
|
-
@buffer.read(size)
|
60
|
-
end
|
61
|
-
|
62
|
-
def eof?
|
63
|
-
@buffer.eof?
|
64
|
-
end
|
65
53
|
end
|
66
54
|
end
|
67
55
|
end
|
@@ -11,10 +11,14 @@ module PG
|
|
11
11
|
Type = Data.define(:oid, :namespace, :name)
|
12
12
|
Insert = Data.define(:oid, :new)
|
13
13
|
Update = Data.define(:oid, :key, :old, :new)
|
14
|
-
Delete = Data.define(:oid, :old)
|
14
|
+
Delete = Data.define(:oid, :key, :old)
|
15
15
|
Truncate = Data.define(:oid)
|
16
16
|
Tuple = Data.define(:type, :data)
|
17
|
-
Column = Data.define(:flags, :name, :oid, :modifier)
|
17
|
+
Column = Data.define(:flags, :name, :oid, :modifier) do
|
18
|
+
def key?
|
19
|
+
flags == 1
|
20
|
+
end
|
21
|
+
end
|
18
22
|
|
19
23
|
def self.read_message(buffer)
|
20
24
|
case buffer.read_char
|
@@ -39,7 +43,7 @@ module PG
|
|
39
43
|
)
|
40
44
|
|
41
45
|
in "C"
|
42
|
-
buffer.read_int8 # Unused
|
46
|
+
buffer.read_int8 # Unused byte
|
43
47
|
PGOutput::Commit.new(
|
44
48
|
lsn: buffer.read_int64,
|
45
49
|
end_lsn: buffer.read_int64,
|
@@ -55,21 +59,16 @@ module PG
|
|
55
59
|
in "R"
|
56
60
|
PGOutput::Relation.new(
|
57
61
|
oid: buffer.read_int32,
|
58
|
-
namespace: buffer.read_cstring,
|
62
|
+
namespace: buffer.read_cstring.then { |ns| ns == "" ? "pg_catalog" : ns },
|
59
63
|
name: buffer.read_cstring,
|
60
|
-
replica_identity: buffer.
|
61
|
-
columns:
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
name: buffer.read_cstring,
|
69
|
-
oid: buffer.read_int32,
|
70
|
-
modifier: buffer.read_int32,
|
71
|
-
)
|
72
|
-
end
|
64
|
+
replica_identity: buffer.read_char,
|
65
|
+
columns: buffer.read_int16.times.map do
|
66
|
+
PGOutput::Column.new(
|
67
|
+
flags: buffer.read_int8,
|
68
|
+
name: buffer.read_cstring,
|
69
|
+
oid: buffer.read_int32,
|
70
|
+
modifier: buffer.read_int32,
|
71
|
+
)
|
73
72
|
end
|
74
73
|
)
|
75
74
|
|
@@ -116,14 +115,23 @@ module PG
|
|
116
115
|
)
|
117
116
|
|
118
117
|
in "D"
|
118
|
+
oid = buffer.read_int32
|
119
|
+
key = []
|
120
|
+
old = []
|
121
|
+
|
122
|
+
until buffer.eof?
|
123
|
+
case buffer.read_char
|
124
|
+
when "K"
|
125
|
+
key = PGOutput.read_tuples(buffer)
|
126
|
+
when "O"
|
127
|
+
old = PGOutput.read_tuples(buffer)
|
128
|
+
end
|
129
|
+
end
|
130
|
+
|
119
131
|
PGOutput::Delete.new(
|
120
|
-
oid
|
121
|
-
|
122
|
-
|
123
|
-
PGOutput.read_tuples(buffer)
|
124
|
-
else
|
125
|
-
[]
|
126
|
-
end,
|
132
|
+
oid:,
|
133
|
+
key:,
|
134
|
+
old:,
|
127
135
|
)
|
128
136
|
|
129
137
|
in "T"
|
@@ -1,11 +1,5 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
-
require "pg"
|
4
|
-
require_relative "version"
|
5
|
-
require_relative "buffer"
|
6
|
-
require_relative "pg_output"
|
7
|
-
require_relative "stream"
|
8
|
-
|
9
3
|
module PG
|
10
4
|
module Replication
|
11
5
|
module Protocol
|
@@ -26,10 +20,7 @@ module PG
|
|
26
20
|
lsn: buffer.read_int64,
|
27
21
|
current_lsn: buffer.read_int64,
|
28
22
|
server_time: buffer.read_timestamp,
|
29
|
-
data: buffer
|
30
|
-
.read
|
31
|
-
.then { |msg| Buffer.from_string(msg) }
|
32
|
-
.then { |msg| PGOutput.read_message(msg) },
|
23
|
+
data: buffer.read,
|
33
24
|
)
|
34
25
|
|
35
26
|
in unknown
|
@@ -37,42 +28,5 @@ module PG
|
|
37
28
|
end
|
38
29
|
end
|
39
30
|
end
|
40
|
-
|
41
|
-
def start_pgoutput_replication_slot(slot, publications, location: "0/0", keep_alive_time: 10)
|
42
|
-
publications = Array(publications)
|
43
|
-
.map { |name| quote_ident(name) }
|
44
|
-
.map { |name| "'#{name}'" }
|
45
|
-
.join(",")
|
46
|
-
|
47
|
-
query(<<~SQL)
|
48
|
-
START_REPLICATION SLOT
|
49
|
-
#{slot} LOGICAL #{location}
|
50
|
-
("proto_version" '1', "publication_names" #{publications})
|
51
|
-
SQL
|
52
|
-
|
53
|
-
last_keep_alive = Time.now
|
54
|
-
last_processed_lsn = 0
|
55
|
-
|
56
|
-
stream = PG::Replication::Stream.new(self)
|
57
|
-
stream.lazy.map do |msg|
|
58
|
-
case msg
|
59
|
-
in PG::Replication::Protocol::XLogData(lsn:, data:)
|
60
|
-
last_processed_lsn = lsn
|
61
|
-
data
|
62
|
-
in PG::Replication::Protocol::PrimaryKeepalive(server_time:, asap: true)
|
63
|
-
stream.standby_status_update(write_lsn: last_processed_lsn)
|
64
|
-
next
|
65
|
-
in PG::Replication::Protocol::PrimaryKeepalive(server_time:)
|
66
|
-
now = Time.now
|
67
|
-
if now - last_keep_alive > keep_alive_time
|
68
|
-
stream.standby_status_update(write_lsn: last_processed_lsn)
|
69
|
-
last_keep_alive = now
|
70
|
-
end
|
71
|
-
next
|
72
|
-
end
|
73
|
-
end
|
74
|
-
end
|
75
31
|
end
|
76
|
-
|
77
|
-
Connection.send(:include, Replication)
|
78
32
|
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "pg"
|
4
|
+
require_relative "replication/version"
|
5
|
+
require_relative "replication/buffer"
|
6
|
+
require_relative "replication/pg_output"
|
7
|
+
require_relative "replication/protocol"
|
8
|
+
|
9
|
+
module PG
|
10
|
+
module Replication
|
11
|
+
def start_replication_slot(slot, logical: true, location: "0/0", **params)
|
12
|
+
keep_alive_secs = query(<<~SQL).getvalue(0, 0)&.to_i || 10
|
13
|
+
SELECT setting FROM pg_catalog.pg_settings WHERE name = 'wal_receiver_status_interval'
|
14
|
+
SQL
|
15
|
+
|
16
|
+
start_query = "START_REPLICATION SLOT #{slot} #{logical ? "LOGICAL" : "PHYSICAL"} #{location}"
|
17
|
+
unless params.empty?
|
18
|
+
start_query << "("
|
19
|
+
start_query << params
|
20
|
+
.map { |k, v| "#{quote_ident(k.to_s)} '#{escape_string(v.to_s)}'" }
|
21
|
+
.join(", ")
|
22
|
+
start_query << ")"
|
23
|
+
end
|
24
|
+
query(start_query)
|
25
|
+
|
26
|
+
last_processed_lsn = 0
|
27
|
+
last_keep_alive = Time.now
|
28
|
+
|
29
|
+
Enumerator
|
30
|
+
.new do |y|
|
31
|
+
loop do
|
32
|
+
if Time.now - last_keep_alive > keep_alive_secs
|
33
|
+
standby_status_update(write_lsn: last_processed_lsn)
|
34
|
+
last_keep_alive = Time.now
|
35
|
+
end
|
36
|
+
|
37
|
+
consume_input
|
38
|
+
next if is_busy
|
39
|
+
|
40
|
+
case get_copy_data(async: true)
|
41
|
+
in nil
|
42
|
+
get_last_result
|
43
|
+
break
|
44
|
+
|
45
|
+
in false
|
46
|
+
IO.select([socket_io], nil, nil, keep_alive_secs)
|
47
|
+
next
|
48
|
+
|
49
|
+
in data
|
50
|
+
buffer = Buffer.new(StringIO.new(data))
|
51
|
+
y << Protocol.read_message(buffer)
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
.lazy
|
56
|
+
.filter_map do |msg|
|
57
|
+
case msg
|
58
|
+
in Protocol::XLogData(lsn:, data:)
|
59
|
+
last_processed_lsn = lsn
|
60
|
+
standby_status_update(write_lsn: last_processed_lsn)
|
61
|
+
last_keep_alive = Time.now
|
62
|
+
data
|
63
|
+
|
64
|
+
in Protocol::PrimaryKeepalive(server_time:, asap: true)
|
65
|
+
standby_status_update(write_lsn: last_processed_lsn)
|
66
|
+
last_keep_alive = Time.now
|
67
|
+
next
|
68
|
+
|
69
|
+
else
|
70
|
+
next
|
71
|
+
end
|
72
|
+
end
|
73
|
+
end
|
74
|
+
|
75
|
+
def start_pgoutput_replication_slot(slot, publication_names, **kwargs)
|
76
|
+
publication_names = publication_names.join(",")
|
77
|
+
|
78
|
+
start_replication_slot(slot, **kwargs.merge(proto_version: "1", publication_names:))
|
79
|
+
.map { |data| data.force_encoding(internal_encoding) }
|
80
|
+
.map { |data| PGOutput.read_message(Buffer.from_string(data)) }
|
81
|
+
end
|
82
|
+
|
83
|
+
def standby_status_update(
|
84
|
+
write_lsn:,
|
85
|
+
flush_lsn: write_lsn,
|
86
|
+
apply_lsn: write_lsn,
|
87
|
+
timestamp: Time.now,
|
88
|
+
reply: false
|
89
|
+
)
|
90
|
+
msg = [
|
91
|
+
"r".bytes.first,
|
92
|
+
write_lsn,
|
93
|
+
flush_lsn,
|
94
|
+
apply_lsn,
|
95
|
+
(timestamp - Time.new(2_000, 1, 1, 0, 0, 0, 0)) * 10**6,
|
96
|
+
reply ? 1 : 0,
|
97
|
+
].pack("CQ>Q>Q>Q>C")
|
98
|
+
|
99
|
+
put_copy_data(msg)
|
100
|
+
flush
|
101
|
+
end
|
102
|
+
end
|
103
|
+
|
104
|
+
Connection.send(:include, Replication)
|
105
|
+
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: pg-replication-protocol
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0.
|
4
|
+
version: 0.0.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Rodrigo Navarro
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2024-11-
|
11
|
+
date: 2024-11-29 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: pg
|
@@ -31,15 +31,16 @@ executables: []
|
|
31
31
|
extensions: []
|
32
32
|
extra_rdoc_files: []
|
33
33
|
files:
|
34
|
+
- ".rspec"
|
34
35
|
- Gemfile
|
35
36
|
- Gemfile.lock
|
36
37
|
- LICENSE.txt
|
37
38
|
- README.md
|
38
39
|
- Rakefile
|
40
|
+
- lib/pg/replication.rb
|
39
41
|
- lib/pg/replication/buffer.rb
|
40
42
|
- lib/pg/replication/pg_output.rb
|
41
43
|
- lib/pg/replication/protocol.rb
|
42
|
-
- lib/pg/replication/stream.rb
|
43
44
|
- lib/pg/replication/version.rb
|
44
45
|
- pg-replication-protocol.gemspec
|
45
46
|
homepage:
|
@@ -1,53 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module PG
|
4
|
-
module Replication
|
5
|
-
class Stream
|
6
|
-
include Enumerable
|
7
|
-
|
8
|
-
def initialize(connection)
|
9
|
-
@connection = connection
|
10
|
-
end
|
11
|
-
|
12
|
-
def each
|
13
|
-
loop do
|
14
|
-
@connection.consume_input
|
15
|
-
|
16
|
-
next if @connection.is_busy
|
17
|
-
|
18
|
-
case @connection.get_copy_data(async: true)
|
19
|
-
in nil
|
20
|
-
@connection.get_last_result
|
21
|
-
break
|
22
|
-
in false
|
23
|
-
IO.select([@connection.socket_io], nil, nil)
|
24
|
-
next
|
25
|
-
in data
|
26
|
-
buffer = Buffer.new(StringIO.new(data))
|
27
|
-
yield Protocol.read_message(buffer)
|
28
|
-
end
|
29
|
-
end
|
30
|
-
end
|
31
|
-
|
32
|
-
def standby_status_update(
|
33
|
-
write_lsn:,
|
34
|
-
flush_lsn: write_lsn,
|
35
|
-
apply_lsn: write_lsn,
|
36
|
-
timestamp: Time.now,
|
37
|
-
reply: false
|
38
|
-
)
|
39
|
-
msg = [
|
40
|
-
"r".bytes.first,
|
41
|
-
write_lsn,
|
42
|
-
flush_lsn,
|
43
|
-
apply_lsn,
|
44
|
-
(timestamp - Time.new(2000, 1, 1, 0, 0, 0, 0)) * 10**6,
|
45
|
-
reply ? 1 : 0,
|
46
|
-
].pack("CQ>Q>Q>Q>C")
|
47
|
-
|
48
|
-
@connection.put_copy_data(msg)
|
49
|
-
@connection.flush
|
50
|
-
end
|
51
|
-
end
|
52
|
-
end
|
53
|
-
end
|