pg-replication-protocol 0.0.1 → 0.0.3

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 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