pgoutput-client 0.0.0 → 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 +4 -4
- data/CHANGELOG.md +31 -2
- data/LICENSE.txt +6 -6
- data/README.md +312 -19
- data/lib/pgoutput/client/commands.rb +62 -0
- data/lib/pgoutput/client/configuration.rb +194 -0
- data/lib/pgoutput/client/connection.rb +120 -0
- data/lib/pgoutput/client/errors.rb +41 -0
- data/lib/pgoutput/client/feedback.rb +62 -0
- data/lib/pgoutput/client/keepalive.rb +64 -0
- data/lib/pgoutput/client/lsn.rb +54 -0
- data/lib/pgoutput/client/stream.rb +102 -0
- data/lib/pgoutput/client/version.rb +4 -1
- data/lib/pgoutput/client/xlog_data.rb +71 -0
- data/lib/pgoutput/client.rb +117 -2
- data/lib/pgoutput_client.rb +12 -0
- data/sig/pg.rbs +12 -0
- data/sig/pgoutput/client/commands.rbs +42 -0
- data/sig/pgoutput/client/configuration.rbs +504 -0
- data/sig/pgoutput/client/connection.rbs +91 -0
- data/sig/pgoutput/client/errors.rbs +43 -0
- data/sig/pgoutput/client/feedback.rbs +71 -0
- data/sig/pgoutput/client/keepalive.rbs +55 -0
- data/sig/pgoutput/client/lsn.rbs +36 -0
- data/sig/pgoutput/client/stream.rbs +68 -0
- data/sig/pgoutput/client/version.rbs +8 -0
- data/sig/pgoutput/client/xlog_data.rbs +63 -0
- data/sig/pgoutput/client.rbs +93 -2
- metadata +46 -10
- data/CODE_OF_CONDUCT.md +0 -10
- data/Rakefile +0 -12
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3dd08a56a5b98573babde8d5aa72e02c44668877803a5272006879c28bd69ebd
|
|
4
|
+
data.tar.gz: c385343185a60a6304ecc276b5f6fac207cb5791fb8035823d863620b238ebda
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 687bd8396cdf3b7a9e019c6cf2c7d584cfa2dcd273dcb5a4c662079d059c03aa2c55b487f9b1d8f58cc82ebfc36421d9d65c774f1fbb6ec6e65ba811418cb82e
|
|
7
|
+
data.tar.gz: 8d5e7c7a2b172084071006ca893e85fcff841539919bab78065a5a70d61fed89929dcd469b0837e959a164a7c3c882734bce24405c2660400c1f06b159e56401
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
|
-
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
|
|
10
|
+
- Placeholder for future development.
|
|
11
|
+
|
|
12
|
+
---
|
|
2
13
|
|
|
3
14
|
## [0.1.0] - 2026-05-31
|
|
4
15
|
|
|
5
|
-
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- Initial transport-only PostgreSQL logical replication client.
|
|
19
|
+
- Added `Pgoutput::Client::Runner` facade.
|
|
20
|
+
- Added immutable configuration object.
|
|
21
|
+
- Added LSN parse and format helpers.
|
|
22
|
+
- Added XLogData envelope parsing.
|
|
23
|
+
- Added primary keepalive parsing.
|
|
24
|
+
- Added standby feedback payload builder.
|
|
25
|
+
- Added replication command builders.
|
|
26
|
+
- Added `PG::Connection` wrapper.
|
|
27
|
+
- Added logical replication stream loop.
|
|
28
|
+
- Added RBS signatures.
|
|
29
|
+
- Added Minitest test suite.
|
|
30
|
+
- Added README and examples.
|
|
31
|
+
|
|
32
|
+
### Notes
|
|
33
|
+
|
|
34
|
+
This release intentionally does not parse pgoutput protocol messages or decode PostgreSQL values.
|
data/LICENSE.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
|
|
1
|
+
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026
|
|
3
|
+
Copyright (c) 2026 Kenneth C. Demanawa
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
|
@@ -9,13 +9,13 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
|
9
9
|
copies of the Software, and to permit persons to whom the Software is
|
|
10
10
|
furnished to do so, subject to the following conditions:
|
|
11
11
|
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
14
|
|
|
15
15
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
16
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
17
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
18
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
19
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
-
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
CHANGED
|
@@ -1,43 +1,336 @@
|
|
|
1
|
-
#
|
|
1
|
+
# pgoutput-client
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://badge.fury.io/rb/pgoutput-client)
|
|
4
|
+
[](https://github.com/kanutocd/pgoutput-client/actions)
|
|
5
|
+
[](https://codecov.io/gh/kanutocd/pgoutput-client)
|
|
6
|
+
[](https://www.ruby-lang.org/en/)
|
|
7
|
+
[](https://opensource.org/licenses/MIT)
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
|
|
10
|
+
A transport-only PostgreSQL logical replication client for receiving raw `pgoutput` payloads in Ruby.
|
|
11
|
+
|
|
12
|
+
`pgoutput-client` connects to PostgreSQL using logical replication, starts a `pgoutput` replication stream, receives `CopyData` messages, handles keepalives, sends standby feedback, and yields raw pgoutput payload bytes to downstream gems such as `pgoutput-parser` and `pgoutput-decoder`.
|
|
13
|
+
|
|
14
|
+
It intentionally does **not** parse row-change messages or decode PostgreSQL values.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Requirements
|
|
19
|
+
|
|
20
|
+
- Ruby 3.4+
|
|
21
|
+
- PostgreSQL 10+
|
|
22
|
+
- `pg` gem
|
|
23
|
+
- PostgreSQL publication and logical replication slot
|
|
24
|
+
|
|
25
|
+
---
|
|
26
|
+
|
|
27
|
+
## Ecosystem Position
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
PostgreSQL logical replication
|
|
31
|
+
│
|
|
32
|
+
▼
|
|
33
|
+
pgoutput-client
|
|
34
|
+
│
|
|
35
|
+
▼
|
|
36
|
+
CopyData / pgoutput payloads
|
|
37
|
+
│
|
|
38
|
+
▼
|
|
39
|
+
pgoutput-parser
|
|
40
|
+
│
|
|
41
|
+
▼
|
|
42
|
+
Protocol messages
|
|
43
|
+
│
|
|
44
|
+
▼
|
|
45
|
+
pgoutput-decoder
|
|
46
|
+
│
|
|
47
|
+
▼
|
|
48
|
+
Decoded row events
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`pgoutput-client` is the transport layer only.
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## Features
|
|
56
|
+
|
|
57
|
+
- Opens PostgreSQL logical replication connections
|
|
58
|
+
- Builds replication commands
|
|
59
|
+
- Supports `CREATE_REPLICATION_SLOT`
|
|
60
|
+
- Supports `DROP_REPLICATION_SLOT`
|
|
61
|
+
- Supports `START_REPLICATION SLOT ... LOGICAL ...`
|
|
62
|
+
- Parses XLogData envelopes
|
|
63
|
+
- Parses primary keepalive messages
|
|
64
|
+
- Builds standby feedback messages
|
|
65
|
+
- Provides LSN parse/format helpers
|
|
66
|
+
- Yields raw pgoutput payload bytes
|
|
67
|
+
- Includes RBS signatures
|
|
68
|
+
- Includes Minitest coverage
|
|
69
|
+
- No audit, parser, or decoder concerns
|
|
70
|
+
|
|
71
|
+
---
|
|
6
72
|
|
|
7
73
|
## Installation
|
|
8
74
|
|
|
9
|
-
|
|
75
|
+
```ruby
|
|
76
|
+
gem "pgoutput-client"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Then:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
bundle install
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Require:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
require "pgoutput-client"
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## Quick Start
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
require "pgoutput-client"
|
|
97
|
+
|
|
98
|
+
client =
|
|
99
|
+
Pgoutput::Client::Runner.new(
|
|
100
|
+
database_url: ENV.fetch("DATABASE_URL"),
|
|
101
|
+
slot_name: "my_slot",
|
|
102
|
+
publication_names: ["my_publication"],
|
|
103
|
+
auto_create_slot: true
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
client.start do |payload, metadata|
|
|
107
|
+
puts "WAL end: #{metadata.wal_end_lsn}"
|
|
108
|
+
puts "Raw pgoutput payload bytes: #{payload.bytesize}"
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
---
|
|
113
|
+
|
|
114
|
+
## Using With pgoutput-parser
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
require "pgoutput-client"
|
|
118
|
+
require "pgoutput"
|
|
119
|
+
|
|
120
|
+
client = Pgoutput::Client::Runner.new(
|
|
121
|
+
database_url: ENV.fetch("DATABASE_URL"),
|
|
122
|
+
slot_name: "my_slot",
|
|
123
|
+
publication_names: ["my_publication"]
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
tracker = Pgoutput::RelationTracker.new
|
|
127
|
+
|
|
128
|
+
client.start do |payload, metadata|
|
|
129
|
+
message = tracker.process(payload)
|
|
130
|
+
p [metadata.wal_end_lsn, message]
|
|
131
|
+
end
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
---
|
|
135
|
+
|
|
136
|
+
## Using With pgoutput-decoder
|
|
137
|
+
|
|
138
|
+
```ruby
|
|
139
|
+
require "pgoutput-client"
|
|
140
|
+
require "pgoutput"
|
|
141
|
+
require "pgoutput/decoder"
|
|
142
|
+
|
|
143
|
+
tracker = Pgoutput::RelationTracker.new
|
|
144
|
+
decoder = Pgoutput::Decoder.new
|
|
145
|
+
|
|
146
|
+
client.start do |payload, metadata|
|
|
147
|
+
protocol_message = tracker.process(payload)
|
|
148
|
+
event = decoder.decode(protocol_message)
|
|
149
|
+
p [metadata.wal_end_lsn, event]
|
|
150
|
+
end
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## What This Gem Does
|
|
156
|
+
|
|
157
|
+
```text
|
|
158
|
+
PostgreSQL replication connection
|
|
159
|
+
│
|
|
160
|
+
▼
|
|
161
|
+
CopyData stream
|
|
162
|
+
│
|
|
163
|
+
▼
|
|
164
|
+
XLogData / Keepalive handling
|
|
165
|
+
│
|
|
166
|
+
▼
|
|
167
|
+
Raw pgoutput payloads
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
It owns:
|
|
171
|
+
|
|
172
|
+
- Replication connection setup
|
|
173
|
+
- Replication command generation
|
|
174
|
+
- CopyData reading
|
|
175
|
+
- XLogData envelope parsing
|
|
176
|
+
- Keepalive handling
|
|
177
|
+
- Standby status feedback
|
|
178
|
+
- LSN conversion
|
|
179
|
+
|
|
180
|
+
---
|
|
181
|
+
|
|
182
|
+
## What This Gem Does Not Do
|
|
183
|
+
|
|
184
|
+
It does not:
|
|
185
|
+
|
|
186
|
+
- Parse pgoutput row messages
|
|
187
|
+
- Decode PostgreSQL OIDs
|
|
188
|
+
- Build application events
|
|
189
|
+
- Group transactions
|
|
190
|
+
- Run processor pipelines
|
|
191
|
+
- Manage Ractor worker pools
|
|
192
|
+
- Store audit records
|
|
193
|
+
|
|
194
|
+
Those responsibilities belong to higher layers.
|
|
10
195
|
|
|
11
|
-
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## Logical Replication Setup
|
|
199
|
+
|
|
200
|
+
Example PostgreSQL setup:
|
|
201
|
+
|
|
202
|
+
```sql
|
|
203
|
+
ALTER SYSTEM SET wal_level = logical;
|
|
204
|
+
|
|
205
|
+
CREATE PUBLICATION my_publication FOR TABLE users, posts;
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Create a slot automatically:
|
|
209
|
+
|
|
210
|
+
```ruby
|
|
211
|
+
Pgoutput::Client::Runner.new(
|
|
212
|
+
database_url: ENV.fetch("DATABASE_URL"),
|
|
213
|
+
slot_name: "my_slot",
|
|
214
|
+
publication_names: ["my_publication"],
|
|
215
|
+
auto_create_slot: true
|
|
216
|
+
)
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
Or create the slot yourself:
|
|
220
|
+
|
|
221
|
+
```sql
|
|
222
|
+
SELECT * FROM pg_create_logical_replication_slot('my_slot', 'pgoutput');
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Public API
|
|
228
|
+
|
|
229
|
+
### Pgoutput::Client::Runner
|
|
230
|
+
|
|
231
|
+
High-level facade.
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
client = Pgoutput::Client::Runner.new(...)
|
|
235
|
+
client.start { |payload, metadata| ... }
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### Pgoutput::Client::Configuration
|
|
239
|
+
|
|
240
|
+
Immutable configuration object.
|
|
241
|
+
|
|
242
|
+
### Pgoutput::Client::Connection
|
|
243
|
+
|
|
244
|
+
Thin wrapper around `PG::Connection` for replication commands.
|
|
245
|
+
|
|
246
|
+
### Pgoutput::Client::Stream
|
|
247
|
+
|
|
248
|
+
Consumes CopyData messages and yields pgoutput payloads.
|
|
249
|
+
|
|
250
|
+
### Pgoutput::Client::LSN
|
|
251
|
+
|
|
252
|
+
```ruby
|
|
253
|
+
Pgoutput::Client::LSN.parse("0/16B6C50")
|
|
254
|
+
Pgoutput::Client::LSN.format(23_817_296)
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
### Pgoutput::Client::XLogData
|
|
258
|
+
|
|
259
|
+
Represents a WAL data envelope.
|
|
260
|
+
|
|
261
|
+
### Pgoutput::Client::Keepalive
|
|
262
|
+
|
|
263
|
+
Represents a primary keepalive message.
|
|
264
|
+
|
|
265
|
+
### Pgoutput::Client::Feedback
|
|
266
|
+
|
|
267
|
+
Builds standby status update payloads.
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## Ractor Position
|
|
272
|
+
|
|
273
|
+
The replication connection itself is stateful and ordered. It should normally run as a single reader.
|
|
274
|
+
|
|
275
|
+
Downstream parsing, decoding, and processing can be parallelized with Ractors:
|
|
276
|
+
|
|
277
|
+
```text
|
|
278
|
+
pgoutput-client reader
|
|
279
|
+
│
|
|
280
|
+
▼
|
|
281
|
+
Ractor-safe queue
|
|
282
|
+
│
|
|
283
|
+
▼
|
|
284
|
+
parser / decoder / processor pools
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
---
|
|
288
|
+
|
|
289
|
+
## Rake Tasks
|
|
290
|
+
|
|
291
|
+
### Default
|
|
292
|
+
|
|
293
|
+
Run them all
|
|
12
294
|
|
|
13
295
|
```bash
|
|
14
|
-
bundle
|
|
296
|
+
bundle exec rake
|
|
15
297
|
```
|
|
16
298
|
|
|
17
|
-
|
|
299
|
+
### Code Linting and Formatting
|
|
18
300
|
|
|
19
301
|
```bash
|
|
20
|
-
|
|
302
|
+
bundle exec rake rubocop
|
|
21
303
|
```
|
|
22
304
|
|
|
23
|
-
|
|
305
|
+
### Testing
|
|
24
306
|
|
|
25
|
-
|
|
307
|
+
```bash
|
|
308
|
+
bundle exec rake test
|
|
309
|
+
```
|
|
26
310
|
|
|
27
|
-
|
|
311
|
+
With coverage:
|
|
28
312
|
|
|
29
|
-
|
|
313
|
+
```bash
|
|
314
|
+
COVERAGE=true bundle exec rake test
|
|
315
|
+
```
|
|
316
|
+
---
|
|
30
317
|
|
|
31
|
-
|
|
318
|
+
### Type Checking
|
|
32
319
|
|
|
33
|
-
|
|
320
|
+
```bash
|
|
321
|
+
bundle exec rbs:validate
|
|
322
|
+
```
|
|
34
323
|
|
|
35
|
-
|
|
324
|
+
---
|
|
36
325
|
|
|
37
|
-
|
|
326
|
+
### Documentation
|
|
327
|
+
|
|
328
|
+
```bash
|
|
329
|
+
bundle exec rake yard
|
|
330
|
+
```
|
|
38
331
|
|
|
39
|
-
|
|
332
|
+
---
|
|
40
333
|
|
|
41
|
-
##
|
|
334
|
+
## License
|
|
42
335
|
|
|
43
|
-
|
|
336
|
+
MIT.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# SQL command builders for PostgreSQL replication-mode commands.
|
|
6
|
+
#
|
|
7
|
+
# PostgreSQL replication commands are issued on a connection opened with the
|
|
8
|
+
# replication parameter enabled. The methods in this module render the small
|
|
9
|
+
# command subset needed by `pgoutput-client` and rely on {Configuration} to
|
|
10
|
+
# validate identifier-like values before interpolation.
|
|
11
|
+
#
|
|
12
|
+
# @api private
|
|
13
|
+
module Commands
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
# Render a `CREATE_REPLICATION_SLOT` command.
|
|
17
|
+
#
|
|
18
|
+
# Temporary slots are requested only when
|
|
19
|
+
# {Configuration#temporary_slot} is true.
|
|
20
|
+
#
|
|
21
|
+
# @example Permanent slot
|
|
22
|
+
# Commands.create_replication_slot(config)
|
|
23
|
+
# # => "CREATE_REPLICATION_SLOT cdc_slot LOGICAL pgoutput"
|
|
24
|
+
#
|
|
25
|
+
# @param configuration [Configuration] replication configuration
|
|
26
|
+
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
27
|
+
def create_replication_slot(configuration)
|
|
28
|
+
temporary = configuration.temporary_slot ? " TEMPORARY" : ""
|
|
29
|
+
"CREATE_REPLICATION_SLOT #{configuration.slot_name}#{temporary} LOGICAL #{configuration.plugin}"
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Render a `DROP_REPLICATION_SLOT` command.
|
|
33
|
+
#
|
|
34
|
+
# @param configuration [Configuration] replication configuration
|
|
35
|
+
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
36
|
+
def drop_replication_slot(configuration)
|
|
37
|
+
"DROP_REPLICATION_SLOT #{configuration.slot_name}"
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Render a `START_REPLICATION SLOT ... LOGICAL ...` command.
|
|
41
|
+
#
|
|
42
|
+
# The command includes the pgoutput options required by PostgreSQL:
|
|
43
|
+
# `proto_version` and `publication_names`. Optional pgoutput switches such
|
|
44
|
+
# as `binary` and `messages` are emitted only when enabled.
|
|
45
|
+
#
|
|
46
|
+
# @param configuration [Configuration] replication configuration
|
|
47
|
+
# @return [String] SQL command suitable for `PG::Connection#exec`
|
|
48
|
+
def start_replication(configuration)
|
|
49
|
+
options = {
|
|
50
|
+
"proto_version" => configuration.proto_version.to_s,
|
|
51
|
+
"publication_names" => configuration.publication_names.join(","),
|
|
52
|
+
"binary" => configuration.binary ? "true" : nil,
|
|
53
|
+
"messages" => configuration.messages ? "true" : nil
|
|
54
|
+
}.compact
|
|
55
|
+
|
|
56
|
+
rendered_options = options.map { |key, value| %("#{key}" '#{value}') }.join(", ")
|
|
57
|
+
|
|
58
|
+
"START_REPLICATION SLOT #{configuration.slot_name} LOGICAL #{configuration.start_lsn_string} (#{rendered_options})"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pgoutput
|
|
4
|
+
module Client
|
|
5
|
+
# Immutable configuration for a PostgreSQL logical replication stream.
|
|
6
|
+
#
|
|
7
|
+
# A configuration describes how `pgoutput-client` should connect to
|
|
8
|
+
# PostgreSQL and how it should request logical replication from the server.
|
|
9
|
+
# It deliberately contains transport-level settings only; parsing pgoutput
|
|
10
|
+
# records and decoding PostgreSQL values belong to downstream layers.
|
|
11
|
+
#
|
|
12
|
+
# The object freezes itself and its string/array attributes during
|
|
13
|
+
# initialization so it can be safely shared by transport components without
|
|
14
|
+
# defensive copying.
|
|
15
|
+
#
|
|
16
|
+
# @example Minimal configuration
|
|
17
|
+
# config = Pgoutput::Client::Configuration.new(
|
|
18
|
+
# database_url: "postgres://localhost/app",
|
|
19
|
+
# slot_name: "cdc_slot",
|
|
20
|
+
# publication_names: "app_publication"
|
|
21
|
+
# )
|
|
22
|
+
#
|
|
23
|
+
# @example Start from a known LSN and request binary values from pgoutput
|
|
24
|
+
# config = Pgoutput::Client::Configuration.new(
|
|
25
|
+
# database_url: ENV.fetch("DATABASE_URL"),
|
|
26
|
+
# slot_name: "cdc_slot",
|
|
27
|
+
# publication_names: %w[app_publication],
|
|
28
|
+
# start_lsn: "0/16B6C50",
|
|
29
|
+
# binary: true
|
|
30
|
+
# )
|
|
31
|
+
#
|
|
32
|
+
# @api public
|
|
33
|
+
class Configuration
|
|
34
|
+
# Default logical decoding output plugin.
|
|
35
|
+
#
|
|
36
|
+
# @return [String]
|
|
37
|
+
DEFAULT_PLUGIN = "pgoutput"
|
|
38
|
+
|
|
39
|
+
# Default pgoutput protocol version.
|
|
40
|
+
#
|
|
41
|
+
# @return [Integer]
|
|
42
|
+
DEFAULT_PROTO_VERSION = 1
|
|
43
|
+
|
|
44
|
+
# Default interval, in seconds, between standby status feedback messages.
|
|
45
|
+
#
|
|
46
|
+
# @return [Float]
|
|
47
|
+
DEFAULT_FEEDBACK_INTERVAL = 10.0
|
|
48
|
+
|
|
49
|
+
# @!attribute [r] database_url
|
|
50
|
+
# PostgreSQL connection URL.
|
|
51
|
+
# @return [String]
|
|
52
|
+
# @!attribute [r] slot_name
|
|
53
|
+
# Logical replication slot name.
|
|
54
|
+
# @return [String]
|
|
55
|
+
# @!attribute [r] publication_names
|
|
56
|
+
# Publication names requested from pgoutput.
|
|
57
|
+
# @return [Array<String>]
|
|
58
|
+
# @!attribute [r] start_lsn
|
|
59
|
+
# Optional normalized starting LSN.
|
|
60
|
+
# @return [String, nil]
|
|
61
|
+
# @!attribute [r] plugin
|
|
62
|
+
# Logical decoding output plugin name.
|
|
63
|
+
# @return [String]
|
|
64
|
+
# @!attribute [r] proto_version
|
|
65
|
+
# pgoutput protocol version.
|
|
66
|
+
# @return [Integer]
|
|
67
|
+
# @!attribute [r] binary
|
|
68
|
+
# Whether to request binary column values from pgoutput.
|
|
69
|
+
# @return [Boolean]
|
|
70
|
+
# @!attribute [r] messages
|
|
71
|
+
# Whether to request logical decoding messages from pgoutput.
|
|
72
|
+
# @return [Boolean]
|
|
73
|
+
# @!attribute [r] auto_create_slot
|
|
74
|
+
# Whether the client should create the slot before streaming.
|
|
75
|
+
# @return [Boolean]
|
|
76
|
+
# @!attribute [r] temporary_slot
|
|
77
|
+
# Whether a newly created slot should be temporary.
|
|
78
|
+
# @return [Boolean]
|
|
79
|
+
# @!attribute [r] feedback_interval
|
|
80
|
+
# Standby feedback interval in seconds.
|
|
81
|
+
# @return [Float]
|
|
82
|
+
attr_reader :database_url,
|
|
83
|
+
:slot_name,
|
|
84
|
+
:publication_names,
|
|
85
|
+
:start_lsn,
|
|
86
|
+
:plugin,
|
|
87
|
+
:proto_version,
|
|
88
|
+
:binary,
|
|
89
|
+
:messages,
|
|
90
|
+
:auto_create_slot,
|
|
91
|
+
:temporary_slot,
|
|
92
|
+
:feedback_interval
|
|
93
|
+
|
|
94
|
+
# Build and validate a logical replication stream configuration.
|
|
95
|
+
#
|
|
96
|
+
# `slot_name` and every publication name are intentionally limited to
|
|
97
|
+
# simple PostgreSQL identifier-like strings. This keeps command rendering
|
|
98
|
+
# small and predictable while avoiding quoting rules in this transport
|
|
99
|
+
# layer.
|
|
100
|
+
#
|
|
101
|
+
# Boolean options are normalized with Ruby truthiness. `nil` and `false`
|
|
102
|
+
# become `false`; all other values become `true`.
|
|
103
|
+
#
|
|
104
|
+
# @param database_url [#to_s] PostgreSQL connection URL
|
|
105
|
+
# @param slot_name [#to_s] logical replication slot name
|
|
106
|
+
# @param publication_names [Array<#to_s>, #to_s] one or more publication
|
|
107
|
+
# names to pass to pgoutput
|
|
108
|
+
# @param start_lsn [String, Integer, nil] starting LSN as a PostgreSQL LSN
|
|
109
|
+
# string, an integer WAL position, or `nil` for `0/0`
|
|
110
|
+
# @param plugin [#to_s] logical decoding plugin name
|
|
111
|
+
# @param proto_version [#to_int, #to_s] pgoutput protocol version
|
|
112
|
+
# @param binary [Object] truthy to request binary column values
|
|
113
|
+
# @param messages [Object] truthy to request logical decoding messages
|
|
114
|
+
# @param auto_create_slot [Object] truthy to create the slot before
|
|
115
|
+
# starting replication
|
|
116
|
+
# @param temporary_slot [Object] truthy to create a temporary replication
|
|
117
|
+
# slot when `auto_create_slot` is enabled
|
|
118
|
+
# @param feedback_interval [#to_f, #to_s] seconds between periodic standby
|
|
119
|
+
# feedback messages
|
|
120
|
+
# @return [void]
|
|
121
|
+
# @raise [ConfigurationError] if publication names are empty or numeric
|
|
122
|
+
# settings are invalid
|
|
123
|
+
# @raise [ArgumentError] if `start_lsn`, `proto_version`, or
|
|
124
|
+
# `feedback_interval` cannot be coerced
|
|
125
|
+
def initialize(database_url:,
|
|
126
|
+
slot_name:,
|
|
127
|
+
publication_names:,
|
|
128
|
+
start_lsn: nil,
|
|
129
|
+
plugin: DEFAULT_PLUGIN,
|
|
130
|
+
proto_version: DEFAULT_PROTO_VERSION,
|
|
131
|
+
binary: false,
|
|
132
|
+
messages: false,
|
|
133
|
+
auto_create_slot: false,
|
|
134
|
+
temporary_slot: false,
|
|
135
|
+
feedback_interval: DEFAULT_FEEDBACK_INTERVAL)
|
|
136
|
+
@database_url = String(database_url).freeze
|
|
137
|
+
@slot_name = validate_identifier(slot_name, "slot_name").freeze
|
|
138
|
+
@publication_names = Array(publication_names).map do |name|
|
|
139
|
+
validate_identifier(name, "publication_name").freeze
|
|
140
|
+
end.freeze
|
|
141
|
+
@start_lsn = normalize_lsn(start_lsn).freeze
|
|
142
|
+
@plugin = String(plugin).freeze
|
|
143
|
+
@proto_version = Integer(proto_version)
|
|
144
|
+
@binary = boolean(binary, "binary")
|
|
145
|
+
@messages = boolean(messages, "messages")
|
|
146
|
+
@auto_create_slot = boolean(auto_create_slot, "auto_create_slot")
|
|
147
|
+
@temporary_slot = boolean(temporary_slot, "temporary_slot")
|
|
148
|
+
@feedback_interval = Float(feedback_interval)
|
|
149
|
+
|
|
150
|
+
validate!
|
|
151
|
+
freeze
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Starting LSN to render in `START_REPLICATION`.
|
|
155
|
+
#
|
|
156
|
+
# @return [String] normalized LSN string, defaulting to `"0/0"`
|
|
157
|
+
def start_lsn_string
|
|
158
|
+
start_lsn || "0/0"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
private
|
|
162
|
+
|
|
163
|
+
def validate!
|
|
164
|
+
raise ConfigurationError, "publication_names must not be empty" if publication_names.empty?
|
|
165
|
+
raise ConfigurationError, "proto_version must be positive" unless proto_version.positive?
|
|
166
|
+
raise ConfigurationError, "feedback_interval must be positive" unless feedback_interval.positive?
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def normalize_lsn(value)
|
|
170
|
+
return nil if value.nil?
|
|
171
|
+
return LSN.format(value) if value.is_a?(Integer)
|
|
172
|
+
|
|
173
|
+
LSN.format(LSN.parse(String(value)))
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate_identifier(value, field)
|
|
177
|
+
string = String(value)
|
|
178
|
+
unless string.match?(/\A[a-zA-Z_][a-zA-Z0-9_]*\z/)
|
|
179
|
+
raise ConfigurationError, "#{field} must be a PostgreSQL identifier-like string"
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
string
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
# Boolean type checking helper
|
|
186
|
+
def boolean(value, name)
|
|
187
|
+
return true if value == true
|
|
188
|
+
return false if value == false
|
|
189
|
+
|
|
190
|
+
raise ArgumentError, "#{name} must be true or false"
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|