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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b723f4c6b03faf3c0ef5a638da7c08d4d65e6a0b723d4a7f00578333705803fc
4
- data.tar.gz: 87577bceaff85726653296a50fd508113be910ecbe305c120d86c12f05ae1276
3
+ metadata.gz: 44b3fb2ad8e9ddb30400a0cf8acaf04a70c09d9ae0641e5e475bdf1394c85d5e
4
+ data.tar.gz: fd766d8b6157af89d017f9dbff382e80278614fe005320c26d7eb89c4f3c4701
5
5
  SHA512:
6
- metadata.gz: 7693f24a4bee5fd0fdf232c18a126fc9f1728a2bad982c4225ebaa7ec7452158a1ec4ffe95f27b7793d7d2184cdeba6d8eb0561c61984c3687eb67d4ad3715b0
7
- data.tar.gz: 97e43186afd2876c52dbaa7d0e8b9235bbdddcc9edcb0e5a5cede9d69617efb08040468de0dcdfbc13966ee93fedcfd6bd9387edb8c63ec173df4622dd3ca6cf
6
+ metadata.gz: 387313faa12bfa5084ea78920f8d860b048d57050e574793e99c9b8fd922cfb60dff8f843a68ce1ba3d05dc010aa011a3b5816a9a8c703e522411c4fff48c1e9
7
+ data.tar.gz: 5df28c6e5fe755a34cf1544607b6439f903a6da5c38f355d41c9d4b8e6e1dbd834a01f2d2ab6cc46d0c9abd20ba98d0fc47fcbbda57146394f962ee59c2f22e1
data/.rspec ADDED
@@ -0,0 +1 @@
1
+ --require spec_helper
data/Gemfile CHANGED
@@ -5,3 +5,8 @@ source "https://rubygems.org"
5
5
  gemspec
6
6
 
7
7
  gem "rake", "~> 13.0"
8
+
9
+ group :test do
10
+ gem "rspec", "~> 3.0"
11
+ gem "testcontainers-postgres", require: "testcontainers/postgres"
12
+ end
data/Gemfile.lock CHANGED
@@ -1,14 +1,37 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- pg-replication-protocol (0.0.1)
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
- # Pg::Replication::Protocol
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
- @buffer.read(1).unpack("C").first
22
+ read(1).unpack("C").first
27
23
  end
28
24
 
29
25
  def read_int16
30
- @buffer.read(2).unpack("n").first
26
+ read(2).unpack("n").first
31
27
  end
32
28
 
33
29
  def read_int32
34
- @buffer.read(4).unpack("N").first
30
+ read(4).unpack("N").first
35
31
  end
36
32
 
37
33
  def read_int64
38
- @buffer.read(8).unpack("Q>").first
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 bytes
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.read_int8,
61
- columns: case buffer.read_int16
62
- in 0
63
- []
64
- in size
65
- size.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
- )
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: buffer.read_int32,
121
- old: case buffer.read_char
122
- when "N", "K"
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module PG
4
4
  module Replication
5
- VERSION = "0.0.1"
5
+ VERSION = "0.0.3"
6
6
  end
7
7
  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.1
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-13 00:00:00.000000000 Z
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