feather-cdc-postgres 0.1.0__py3-none-any.whl
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.
- feather_cdc_postgres/__init__.py +6 -0
- feather_cdc_postgres/__main__.py +9 -0
- feather_cdc_postgres/adapters/__init__.py +44 -0
- feather_cdc_postgres/adapters/featherbase.py +126 -0
- feather_cdc_postgres/cli.py +247 -0
- feather_cdc_postgres/destination.py +640 -0
- feather_cdc_postgres/discovery.py +216 -0
- feather_cdc_postgres/drift.py +207 -0
- feather_cdc_postgres/generate_dbt.py +106 -0
- feather_cdc_postgres/lifecycle.py +79 -0
- feather_cdc_postgres/manifest.py +587 -0
- feather_cdc_postgres/ownership.py +103 -0
- feather_cdc_postgres/publication.py +157 -0
- feather_cdc_postgres/runner.py +1399 -0
- feather_cdc_postgres/schema_registry.py +283 -0
- feather_cdc_postgres/selectors.py +347 -0
- feather_cdc_postgres/snapshot.py +448 -0
- feather_cdc_postgres/stream.py +162 -0
- feather_cdc_postgres/telemetry.py +172 -0
- feather_cdc_postgres-0.1.0.dist-info/METADATA +239 -0
- feather_cdc_postgres-0.1.0.dist-info/RECORD +30 -0
- feather_cdc_postgres-0.1.0.dist-info/WHEEL +4 -0
- feather_cdc_postgres-0.1.0.dist-info/entry_points.txt +2 -0
- pg_replication/LICENSE +201 -0
- pg_replication/README.md +100 -0
- pg_replication/__init__.py +106 -0
- pg_replication/decoders.py +428 -0
- pg_replication/exceptions.py +6 -0
- pg_replication/helpers.py +1405 -0
- pg_replication/schema_types.py +156 -0
pg_replication/README.md
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
VENDORED THIRD-PARTY CODE — read this before editing anything in this directory.
|
|
3
|
+
|
|
4
|
+
Origin: the `pg_replication` verified source of https://github.com/dlt-hub/verified-sources
|
|
5
|
+
Licence: Apache License 2.0, "Copyright 2023 The Contributors" — the full text is in
|
|
6
|
+
./LICENSE, distributed with this package as section 4(a) requires.
|
|
7
|
+
|
|
8
|
+
MODIFIED. Section 4(b) of the licence asks that changed files carry a prominent notice,
|
|
9
|
+
so: `helpers.py` and `decoders.py` here are not upstream's. They carry the column
|
|
10
|
+
deny-list projection, the reviewed-layout quarantine, mid-transaction rename handling,
|
|
11
|
+
consistent baseline boundaries and generated-column publication that this runtime needs.
|
|
12
|
+
|
|
13
|
+
It is vendored rather than depended on because upstream is a `dlt init` TEMPLATE, not a
|
|
14
|
+
published distribution: there is no `pg_replication` on PyPI to pin. Upstream's own
|
|
15
|
+
requirements are incomplete (dlt-hub/verified-sources#685), which is why this package
|
|
16
|
+
declares `dlt[sql_database]` explicitly.
|
|
17
|
+
|
|
18
|
+
The README below is upstream's, kept for the protocol and set-up notes it carries.
|
|
19
|
+
-->
|
|
20
|
+
|
|
21
|
+
# Postgres replication
|
|
22
|
+
[Postgres](https://www.postgresql.org/) is one of the most popular relational database management systems. This verified source uses Postgres' replication functionality to efficiently process changes in tables (a process often referred to as _Change Data Capture_ or CDC). It uses [logical decoding](https://www.postgresql.org/docs/current/logicaldecoding.html) and the standard built-in `pgoutput` [output plugin](https://www.postgresql.org/docs/current/logicaldecoding-output-plugin.html).
|
|
23
|
+
|
|
24
|
+
Resources that can be loaded using this verified source are:
|
|
25
|
+
|
|
26
|
+
| Name | Description |
|
|
27
|
+
|----------------------|-------------------------------------------------|
|
|
28
|
+
| replication_resource | Load published messages from a replication slot |
|
|
29
|
+
| init_replication | Initialize replication and optionally return snapshot resources for initial data load |
|
|
30
|
+
|
|
31
|
+
## Initialize the pipeline
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
dlt init pg_replication duckdb
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
This uses `duckdb` as destination, but you can choose any of the supported [destinations](https://dlthub.com/docs/dlt-ecosystem/destinations/).
|
|
38
|
+
|
|
39
|
+
## Set up user
|
|
40
|
+
|
|
41
|
+
The Postgres user needs to have the `LOGIN` and `REPLICATION` attributes assigned:
|
|
42
|
+
|
|
43
|
+
```sql
|
|
44
|
+
CREATE ROLE replication_user WITH LOGIN REPLICATION;
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
It also needs `CREATE` privilege on the database:
|
|
48
|
+
|
|
49
|
+
```sql
|
|
50
|
+
GRANT CREATE ON DATABASE dlt_data TO replication_user;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
If not a superuser, the user must have ownership of the tables that need to be replicated:
|
|
54
|
+
|
|
55
|
+
```sql
|
|
56
|
+
ALTER TABLE your_table OWNER TO replication_user;
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
### Set up RDS
|
|
61
|
+
1. You must enable replication for RDS Postgres instance via **Parameter Group**: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_PostgreSQL.Replication.ReadReplicas.html
|
|
62
|
+
2. `WITH LOGIN REPLICATION;` does not work on RDS, instead do:
|
|
63
|
+
```sql
|
|
64
|
+
GRANT rds_replication TO replication_user;
|
|
65
|
+
```
|
|
66
|
+
3. Do not fallback to non SSL connection by setting connection parameters:
|
|
67
|
+
```toml
|
|
68
|
+
sources.pg_replication.credentials="postgresql://loader:password@host.rds.amazonaws.com:5432/dlt_data?sslmode=require&connect_timeout=300"
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
## Add credentials
|
|
73
|
+
1. Open `.dlt/secrets.toml`.
|
|
74
|
+
2. Enter your Postgres credentials:
|
|
75
|
+
|
|
76
|
+
```toml
|
|
77
|
+
[sources.pg_replication]
|
|
78
|
+
credentials="postgresql://replication_user:<<password>>@localhost:5432/dlt_data"
|
|
79
|
+
```
|
|
80
|
+
3. Enter credentials for your chosen destination as per the [docs](https://dlthub.com/docs/dlt-ecosystem/destinations/).
|
|
81
|
+
|
|
82
|
+
## Run the pipeline
|
|
83
|
+
|
|
84
|
+
1. Install the necessary dependencies by running the following command:
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
pip install -r requirements.txt
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
1. Now the pipeline can be run by using the command:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
python pg_replication_pipeline.py
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
1. To make sure that everything is loaded as expected, use the command:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
dlt pipeline pg_replication_pipeline show
|
|
100
|
+
```
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Replicates postgres tables in batch using logical decoding."""
|
|
2
|
+
|
|
3
|
+
from typing import Dict, Sequence, Optional, Iterable, Union
|
|
4
|
+
|
|
5
|
+
import dlt
|
|
6
|
+
|
|
7
|
+
from dlt.common import logger
|
|
8
|
+
from dlt.common.typing import TDataItem
|
|
9
|
+
from dlt.common.schema.typing import TTableSchemaColumns
|
|
10
|
+
from dlt.extract.items import DataItemWithMeta
|
|
11
|
+
from dlt.sources.credentials import ConnectionStringCredentials
|
|
12
|
+
|
|
13
|
+
from .helpers import advance_slot, get_max_lsn, ItemGenerator
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dlt.resource(
|
|
17
|
+
name=lambda args: args["slot_name"],
|
|
18
|
+
standalone=True,
|
|
19
|
+
)
|
|
20
|
+
def replication_resource(
|
|
21
|
+
slot_name: str,
|
|
22
|
+
pub_name: str,
|
|
23
|
+
credentials: ConnectionStringCredentials = dlt.secrets.value,
|
|
24
|
+
include_columns: Optional[Dict[str, Sequence[str]]] = None,
|
|
25
|
+
columns: Optional[Dict[str, TTableSchemaColumns]] = None,
|
|
26
|
+
target_batch_size: int = 1000,
|
|
27
|
+
flush_slot: bool = True,
|
|
28
|
+
) -> Iterable[Union[TDataItem, DataItemWithMeta]]:
|
|
29
|
+
"""Resource yielding data items for changes in one or more postgres tables.
|
|
30
|
+
|
|
31
|
+
- Relies on a replication slot and publication that publishes DML operations
|
|
32
|
+
(i.e. `insert`, `update`, and/or `delete`). Helper `init_replication` can be
|
|
33
|
+
used to set this up.
|
|
34
|
+
- Maintains LSN of last consumed message in state to track progress.
|
|
35
|
+
- At start of the run, advances the slot upto last consumed message in previous run.
|
|
36
|
+
- Processes in batches to limit memory usage.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
slot_name (str): Name of the replication slot to consume replication messages from.
|
|
40
|
+
pub_name (str): Name of the publication that publishes DML operations for the table(s).
|
|
41
|
+
credentials (ConnectionStringCredentials): Postgres database credentials.
|
|
42
|
+
include_columns (Optional[Dict[str, Sequence[str]]]): Maps table name(s) to
|
|
43
|
+
sequence of names of columns to include in the generated data items.
|
|
44
|
+
Any column not in the sequence is excluded. If not provided, all columns
|
|
45
|
+
are included. For example:
|
|
46
|
+
```
|
|
47
|
+
include_columns={
|
|
48
|
+
"table_x": ["col_a", "col_c"],
|
|
49
|
+
"table_y": ["col_x", "col_y", "col_z"],
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
columns (Optional[Dict[str, TTableHintTemplate[TAnySchemaColumns]]]): Maps
|
|
53
|
+
table name(s) to column hints to apply on the replicated table(s). For example:
|
|
54
|
+
```
|
|
55
|
+
columns={
|
|
56
|
+
"table_x": {"col_a": {"data_type": "json"}},
|
|
57
|
+
"table_y": {"col_y": {"precision": 32}},
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
target_batch_size (int): Desired number of data items yielded in a batch.
|
|
61
|
+
Can be used to limit the data items in memory. Note that the number of
|
|
62
|
+
data items yielded can be (far) greater than `target_batch_size`, because
|
|
63
|
+
all messages belonging to the same transaction are always processed in
|
|
64
|
+
the same batch, regardless of the number of messages in the transaction
|
|
65
|
+
and regardless of the value of `target_batch_size`. The number of data
|
|
66
|
+
items can also be smaller than `target_batch_size` when the replication
|
|
67
|
+
slot is exhausted before a batch is full.
|
|
68
|
+
flush_slot (bool): Whether processed messages are discarded from the replication
|
|
69
|
+
slot. Recommended value is True. Be careful when setting False—not flushing
|
|
70
|
+
can eventually lead to a “disk full” condition on the server, because
|
|
71
|
+
the server retains all the WAL segments that might be needed to stream
|
|
72
|
+
the changes via all of the currently open replication slots.
|
|
73
|
+
|
|
74
|
+
Yields:
|
|
75
|
+
Data items for changes published in the publication.
|
|
76
|
+
"""
|
|
77
|
+
# start where we left off in previous run
|
|
78
|
+
start_lsn = dlt.current.resource_state().get("last_commit_lsn", 0)
|
|
79
|
+
if flush_slot and start_lsn:
|
|
80
|
+
advance_slot(start_lsn, slot_name, credentials)
|
|
81
|
+
|
|
82
|
+
# continue until last message in replication slot
|
|
83
|
+
options = {"publication_names": pub_name, "proto_version": "1"}
|
|
84
|
+
upto_lsn = get_max_lsn(slot_name, options, credentials)
|
|
85
|
+
if upto_lsn is None:
|
|
86
|
+
return
|
|
87
|
+
logger.info(
|
|
88
|
+
f"Replicating slot {slot_name} publication {pub_name} from {start_lsn} to {upto_lsn}"
|
|
89
|
+
)
|
|
90
|
+
# generate items in batches
|
|
91
|
+
while True:
|
|
92
|
+
gen = ItemGenerator(
|
|
93
|
+
credentials=credentials,
|
|
94
|
+
slot_name=slot_name,
|
|
95
|
+
options=options,
|
|
96
|
+
upto_lsn=upto_lsn,
|
|
97
|
+
start_lsn=start_lsn,
|
|
98
|
+
target_batch_size=target_batch_size,
|
|
99
|
+
include_columns=include_columns,
|
|
100
|
+
columns=columns,
|
|
101
|
+
)
|
|
102
|
+
yield from gen
|
|
103
|
+
if gen.generated_all:
|
|
104
|
+
dlt.current.resource_state()["last_commit_lsn"] = gen.last_commit_lsn
|
|
105
|
+
break
|
|
106
|
+
start_lsn = gen.last_commit_lsn
|
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
# flake8: noqa
|
|
2
|
+
# file copied from https://raw.githubusercontent.com/dgea005/pypgoutput/master/src/pypgoutput/decoders.py
|
|
3
|
+
# we do this instead of importing `pypgoutput` because it depends on `psycopg2`, which causes errors when installing on macOS
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from datetime import datetime, timedelta, timezone
|
|
9
|
+
from typing import List, NamedTuple, Optional, Union
|
|
10
|
+
|
|
11
|
+
# integer byte lengths
|
|
12
|
+
INT8 = 1
|
|
13
|
+
INT16 = 2
|
|
14
|
+
INT32 = 4
|
|
15
|
+
INT64 = 8
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def convert_pg_ts(_ts_in_microseconds: int) -> datetime:
|
|
19
|
+
ts = datetime(2000, 1, 1, 0, 0, 0, 0, tzinfo=timezone.utc)
|
|
20
|
+
return ts + timedelta(microseconds=_ts_in_microseconds)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def convert_bytes_to_int(_in_bytes: bytes) -> int:
|
|
24
|
+
return int.from_bytes(_in_bytes, byteorder="big", signed=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def convert_bytes_to_utf8(_in_bytes: Union[bytes, bytearray]) -> str:
|
|
28
|
+
return (_in_bytes).decode("utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ColumnData(NamedTuple):
|
|
32
|
+
# col_data_category is NOT the type. it means null value/toasted(not sent)/text formatted
|
|
33
|
+
col_data_category: Optional[str]
|
|
34
|
+
col_data_length: Optional[int] = None
|
|
35
|
+
col_data: Optional[str] = None
|
|
36
|
+
|
|
37
|
+
def __repr__(self) -> str:
|
|
38
|
+
return f"[col_data_category='{self.col_data_category}', col_data_length={self.col_data_length}, col_data='{self.col_data}']"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ColumnType(NamedTuple):
|
|
42
|
+
"""https://www.postgresql.org/docs/12/catalog-pg-attribute.html"""
|
|
43
|
+
|
|
44
|
+
part_of_pkey: int
|
|
45
|
+
name: str
|
|
46
|
+
type_id: int
|
|
47
|
+
atttypmod: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class TupleData(NamedTuple):
|
|
51
|
+
n_columns: int
|
|
52
|
+
column_data: List[ColumnData]
|
|
53
|
+
|
|
54
|
+
def __repr__(self) -> str:
|
|
55
|
+
return f"n_columns: {self.n_columns}, data: {self.column_data}"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# TODO: you can make decoding way faster by
|
|
59
|
+
# - moving all the decoding core to PgoutputMessage
|
|
60
|
+
# - use struct unpack and increase offset manually to reduce calls
|
|
61
|
+
# - use tuples to represent data, separate data from decoding!
|
|
62
|
+
class PgoutputMessage(ABC):
|
|
63
|
+
def __init__(self, buffer: bytes):
|
|
64
|
+
self.buffer: io.BytesIO = io.BytesIO(buffer)
|
|
65
|
+
self.byte1: str = self.read_utf8(1)
|
|
66
|
+
self.decode_buffer()
|
|
67
|
+
|
|
68
|
+
@abstractmethod
|
|
69
|
+
def decode_buffer(self) -> None:
|
|
70
|
+
"""Decoding is implemented for each message type"""
|
|
71
|
+
|
|
72
|
+
@abstractmethod
|
|
73
|
+
def __repr__(self) -> str:
|
|
74
|
+
"""Implemented for each message type"""
|
|
75
|
+
|
|
76
|
+
def read_int8(self) -> int:
|
|
77
|
+
return convert_bytes_to_int(self.buffer.read(INT8))
|
|
78
|
+
|
|
79
|
+
def read_int16(self) -> int:
|
|
80
|
+
return convert_bytes_to_int(self.buffer.read(INT16))
|
|
81
|
+
|
|
82
|
+
def read_int32(self) -> int:
|
|
83
|
+
return convert_bytes_to_int(self.buffer.read(INT32))
|
|
84
|
+
|
|
85
|
+
def read_int64(self) -> int:
|
|
86
|
+
return convert_bytes_to_int(self.buffer.read(INT64))
|
|
87
|
+
|
|
88
|
+
def read_utf8(self, n: int = 1) -> str:
|
|
89
|
+
return convert_bytes_to_utf8(self.buffer.read(n))
|
|
90
|
+
|
|
91
|
+
def read_timestamp(self) -> datetime:
|
|
92
|
+
# 8 chars -> int64 -> timestamp
|
|
93
|
+
return convert_pg_ts(_ts_in_microseconds=self.read_int64())
|
|
94
|
+
|
|
95
|
+
def read_string(self) -> str:
|
|
96
|
+
output = bytearray()
|
|
97
|
+
while (next_char := self.buffer.read(1)) != b"\x00":
|
|
98
|
+
output += next_char
|
|
99
|
+
return convert_bytes_to_utf8(output)
|
|
100
|
+
|
|
101
|
+
def read_tuple_data(self) -> TupleData:
|
|
102
|
+
"""
|
|
103
|
+
TupleData
|
|
104
|
+
Int16 Number of columns.
|
|
105
|
+
Next, one of the following submessages appears for each column (except generated columns):
|
|
106
|
+
Byte1('n') Identifies the data as NULL value.
|
|
107
|
+
Or
|
|
108
|
+
Byte1('u') Identifies unchanged TOASTed value (the actual value is not sent).
|
|
109
|
+
Or
|
|
110
|
+
Byte1('t') Identifies the data as text formatted value.
|
|
111
|
+
Int32 Length of the column value.
|
|
112
|
+
Byten The value of the column, in text format. (A future release might support additional formats.) n is the above length.
|
|
113
|
+
"""
|
|
114
|
+
# TODO: investigate what happens with the generated columns
|
|
115
|
+
column_data = list()
|
|
116
|
+
n_columns = self.read_int16()
|
|
117
|
+
for column in range(n_columns):
|
|
118
|
+
col_data_category = self.read_utf8()
|
|
119
|
+
if col_data_category in ("n", "u"):
|
|
120
|
+
# "n"=NULL, "t"=TOASTed
|
|
121
|
+
column_data.append(ColumnData(col_data_category=col_data_category))
|
|
122
|
+
elif col_data_category == "t":
|
|
123
|
+
# t = tuple
|
|
124
|
+
col_data_length = self.read_int32()
|
|
125
|
+
col_data = self.read_utf8(col_data_length)
|
|
126
|
+
column_data.append(
|
|
127
|
+
ColumnData(
|
|
128
|
+
col_data_category=col_data_category,
|
|
129
|
+
col_data_length=col_data_length,
|
|
130
|
+
col_data=col_data,
|
|
131
|
+
)
|
|
132
|
+
)
|
|
133
|
+
return TupleData(n_columns=n_columns, column_data=column_data)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class Begin(PgoutputMessage):
|
|
137
|
+
"""
|
|
138
|
+
https://pgpedia.info/x/xlogrecptr.html
|
|
139
|
+
https://www.postgresql.org/docs/14/datatype-pg-lsn.html
|
|
140
|
+
|
|
141
|
+
byte1 Byte1('B') Identifies the message as a begin message.
|
|
142
|
+
lsn Int64 The final LSN of the transaction.
|
|
143
|
+
commit_tx_ts Int64 Commit timestamp of the transaction. The value is in number of microseconds since PostgreSQL epoch (2000-01-01).
|
|
144
|
+
tx_xid Int32 Xid of the transaction.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
byte1: str
|
|
148
|
+
lsn: int
|
|
149
|
+
commit_ts: datetime
|
|
150
|
+
tx_xid: int
|
|
151
|
+
|
|
152
|
+
def decode_buffer(self) -> None:
|
|
153
|
+
if self.byte1 != "B":
|
|
154
|
+
raise ValueError("first byte in buffer does not match Begin message")
|
|
155
|
+
self.lsn = self.read_int64()
|
|
156
|
+
self.commit_ts = self.read_timestamp()
|
|
157
|
+
self.tx_xid = self.read_int64()
|
|
158
|
+
|
|
159
|
+
def __repr__(self) -> str:
|
|
160
|
+
return (
|
|
161
|
+
f"BEGIN \n\tbyte1: '{self.byte1}', \n\tLSN: {self.lsn}, "
|
|
162
|
+
f"\n\tcommit_ts {self.commit_ts}, \n\ttx_xid: {self.tx_xid}"
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class Commit(PgoutputMessage):
|
|
167
|
+
"""
|
|
168
|
+
byte1: Byte1('C') Identifies the message as a commit message.
|
|
169
|
+
flags: Int8 Flags; currently unused (must be 0).
|
|
170
|
+
lsn_commit: Int64 The LSN of the commit.
|
|
171
|
+
lsn: Int64 The end LSN of the transaction.
|
|
172
|
+
Int64 Commit timestamp of the transaction. The value is in number of microseconds since PostgreSQL epoch (2000-01-01).
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
byte1: str
|
|
176
|
+
flags: int
|
|
177
|
+
lsn_commit: int
|
|
178
|
+
lsn: int
|
|
179
|
+
commit_ts: datetime
|
|
180
|
+
|
|
181
|
+
def decode_buffer(self) -> None:
|
|
182
|
+
if self.byte1 != "C":
|
|
183
|
+
raise ValueError("first byte in buffer does not match Commit message")
|
|
184
|
+
self.flags = self.read_int8()
|
|
185
|
+
self.lsn_commit = self.read_int64()
|
|
186
|
+
self.lsn = self.read_int64()
|
|
187
|
+
self.commit_ts = self.read_timestamp()
|
|
188
|
+
|
|
189
|
+
def __repr__(self) -> str:
|
|
190
|
+
return (
|
|
191
|
+
f"COMMIT \n\tbyte1: {self.byte1}, \n\tflags {self.flags}, \n\tlsn_commit: {self.lsn_commit}"
|
|
192
|
+
f"\n\tLSN: {self.lsn}, \n\tcommit_ts {self.commit_ts}"
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class Origin:
|
|
197
|
+
"""
|
|
198
|
+
Byte1('O') Identifies the message as an origin message.
|
|
199
|
+
Int64 The LSN of the commit on the origin server.
|
|
200
|
+
String Name of the origin.
|
|
201
|
+
Note that there can be multiple Origin messages inside a single transaction.
|
|
202
|
+
This seems to be what origin means: https://www.postgresql.org/docs/12/replication-origins.html
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class Relation(PgoutputMessage):
|
|
209
|
+
"""
|
|
210
|
+
Byte1('R') Identifies the message as a relation message.
|
|
211
|
+
Int32 ID of the relation.
|
|
212
|
+
String Namespace (empty string for pg_catalog).
|
|
213
|
+
String Relation name.
|
|
214
|
+
Int8 Replica identity setting for the relation (same as relreplident in pg_class).
|
|
215
|
+
# select relreplident from pg_class where relname = 'test_table';
|
|
216
|
+
# from reading the documentation and looking at the tables this is not int8 but a single character
|
|
217
|
+
# background: https://www.postgresql.org/docs/10/sql-altertable.html#SQL-CREATETABLE-REPLICA-IDENTITY
|
|
218
|
+
Int16 Number of columns.
|
|
219
|
+
Next, the following message part appears for each column (except generated columns):
|
|
220
|
+
Int8 Flags for the column. Currently can be either 0 for no flags or 1 which marks the column as part of the key.
|
|
221
|
+
String Name of the column.
|
|
222
|
+
Int32 ID of the column's data type.
|
|
223
|
+
Int32 Type modifier of the column (atttypmod).
|
|
224
|
+
"""
|
|
225
|
+
|
|
226
|
+
byte1: str
|
|
227
|
+
relation_id: int
|
|
228
|
+
namespace: str
|
|
229
|
+
relation_name: str
|
|
230
|
+
replica_identity_setting: str
|
|
231
|
+
n_columns: int
|
|
232
|
+
columns: List[ColumnType]
|
|
233
|
+
|
|
234
|
+
def decode_buffer(self) -> None:
|
|
235
|
+
if self.byte1 != "R":
|
|
236
|
+
raise ValueError("first byte in buffer does not match Relation message")
|
|
237
|
+
self.relation_id = self.read_int32()
|
|
238
|
+
self.namespace = self.read_string()
|
|
239
|
+
self.relation_name = self.read_string()
|
|
240
|
+
self.replica_identity_setting = self.read_utf8()
|
|
241
|
+
self.n_columns = self.read_int16()
|
|
242
|
+
self.columns = list()
|
|
243
|
+
|
|
244
|
+
for column in range(self.n_columns):
|
|
245
|
+
part_of_pkey = self.read_int8()
|
|
246
|
+
col_name = self.read_string()
|
|
247
|
+
data_type_id = self.read_int32()
|
|
248
|
+
# TODO: check on use of signed / unsigned
|
|
249
|
+
# check with select oid from pg_type where typname = <type>; timestamp == 1184, int4 = 23
|
|
250
|
+
col_modifier = self.read_int32()
|
|
251
|
+
self.columns.append(
|
|
252
|
+
ColumnType(
|
|
253
|
+
part_of_pkey=part_of_pkey,
|
|
254
|
+
name=col_name,
|
|
255
|
+
type_id=data_type_id,
|
|
256
|
+
atttypmod=col_modifier,
|
|
257
|
+
)
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
def __repr__(self) -> str:
|
|
261
|
+
return (
|
|
262
|
+
f"RELATION \n\tbyte1: '{self.byte1}', \n\trelation_id: {self.relation_id}"
|
|
263
|
+
f",\n\tnamespace/schema: '{self.namespace}',\n\trelation_name: '{self.relation_name}'"
|
|
264
|
+
f",\n\treplica_identity_setting: '{self.replica_identity_setting}',\n\tn_columns: {self.n_columns} "
|
|
265
|
+
f",\n\tcolumns: {self.columns}"
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class PgType:
|
|
270
|
+
"""
|
|
271
|
+
Renamed to PgType not to collide with "type"
|
|
272
|
+
|
|
273
|
+
Byte1('Y') Identifies the message as a type message.
|
|
274
|
+
Int32 ID of the data type.
|
|
275
|
+
String Namespace (empty string for pg_catalog).
|
|
276
|
+
String Name of the data type.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
pass
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
class Insert(PgoutputMessage):
|
|
283
|
+
"""
|
|
284
|
+
Byte1('I') Identifies the message as an insert message.
|
|
285
|
+
Int32 ID of the relation corresponding to the ID in the relation message.
|
|
286
|
+
Byte1('N') Identifies the following TupleData message as a new tuple.
|
|
287
|
+
TupleData TupleData message part representing the contents of new tuple.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
byte1: str
|
|
291
|
+
relation_id: int
|
|
292
|
+
new_tuple_byte: str
|
|
293
|
+
new_tuple: TupleData
|
|
294
|
+
|
|
295
|
+
def decode_buffer(self) -> None:
|
|
296
|
+
if self.byte1 != "I":
|
|
297
|
+
raise ValueError(
|
|
298
|
+
f"first byte in buffer does not match Insert message (expected 'I', got '{self.byte1}'"
|
|
299
|
+
)
|
|
300
|
+
self.relation_id = self.read_int32()
|
|
301
|
+
self.new_tuple_byte = self.read_utf8()
|
|
302
|
+
self.new_tuple = self.read_tuple_data()
|
|
303
|
+
|
|
304
|
+
def __repr__(self) -> str:
|
|
305
|
+
return (
|
|
306
|
+
f"INSERT \n\tbyte1: '{self.byte1}', \n\trelation_id: {self.relation_id} "
|
|
307
|
+
f"\n\tnew tuple byte: '{self.new_tuple_byte}', \n\tnew_tuple: {self.new_tuple}"
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class Update(PgoutputMessage):
|
|
312
|
+
"""
|
|
313
|
+
Byte1('U') Identifies the message as an update message.
|
|
314
|
+
Int32 ID of the relation corresponding to the ID in the relation message.
|
|
315
|
+
Byte1('K') Identifies the following TupleData submessage as a key. This field is optional and is only present if the update changed data in any of the column(s) that are part of the REPLICA IDENTITY index.
|
|
316
|
+
Byte1('O') Identifies the following TupleData submessage as an old tuple. This field is optional and is only present if table in which the update happened has REPLICA IDENTITY set to FULL.
|
|
317
|
+
TupleData TupleData message part representing the contents of the old tuple or primary key. Only present if the previous 'O' or 'K' part is present.
|
|
318
|
+
Byte1('N') Identifies the following TupleData message as a new tuple.
|
|
319
|
+
TupleData TupleData message part representing the contents of a new tuple.
|
|
320
|
+
|
|
321
|
+
The Update message may contain either a 'K' message part or an 'O' message part or neither of them, but never both of them.
|
|
322
|
+
"""
|
|
323
|
+
|
|
324
|
+
byte1: str
|
|
325
|
+
relation_id: int
|
|
326
|
+
next_byte_identifier: Optional[str]
|
|
327
|
+
optional_tuple_identifier: Optional[str]
|
|
328
|
+
old_tuple: Optional[TupleData]
|
|
329
|
+
new_tuple_byte: str
|
|
330
|
+
new_tuple: TupleData
|
|
331
|
+
|
|
332
|
+
def decode_buffer(self) -> None:
|
|
333
|
+
self.optional_tuple_identifier = None
|
|
334
|
+
self.old_tuple = None
|
|
335
|
+
if self.byte1 != "U":
|
|
336
|
+
raise ValueError(
|
|
337
|
+
f"first byte in buffer does not match Update message (expected 'U', got '{self.byte1}'"
|
|
338
|
+
)
|
|
339
|
+
self.relation_id = self.read_int32()
|
|
340
|
+
# TODO test update to PK, test update with REPLICA IDENTITY = FULL
|
|
341
|
+
self.next_byte_identifier = self.read_utf8() # one of K, O or N
|
|
342
|
+
if self.next_byte_identifier == "K" or self.next_byte_identifier == "O":
|
|
343
|
+
self.optional_tuple_identifier = self.next_byte_identifier
|
|
344
|
+
self.old_tuple = self.read_tuple_data()
|
|
345
|
+
self.new_tuple_byte = self.read_utf8()
|
|
346
|
+
else:
|
|
347
|
+
self.new_tuple_byte = self.next_byte_identifier
|
|
348
|
+
if self.new_tuple_byte != "N":
|
|
349
|
+
# TODO: test exception handling
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"did not find new_tuple_byte ('N') at position: {self.buffer.tell()}, found: '{self.new_tuple_byte}'"
|
|
352
|
+
)
|
|
353
|
+
self.new_tuple = self.read_tuple_data()
|
|
354
|
+
|
|
355
|
+
def __repr__(self) -> str:
|
|
356
|
+
return (
|
|
357
|
+
f"UPDATE \n\tbyte1: '{self.byte1}', \n\trelation_id: {self.relation_id}"
|
|
358
|
+
f"\n\toptional_tuple_identifier: '{self.optional_tuple_identifier}', \n\toptional_old_tuple_data: {self.old_tuple}"
|
|
359
|
+
f"\n\tnew_tuple_byte: '{self.new_tuple_byte}', \n\tnew_tuple: {self.new_tuple}"
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class Delete(PgoutputMessage):
|
|
364
|
+
"""
|
|
365
|
+
Byte1('D') Identifies the message as a delete message.
|
|
366
|
+
Int32 ID of the relation corresponding to the ID in the relation message.
|
|
367
|
+
Byte1('K') Identifies the following TupleData submessage as a key. This field is present if the table in which the delete has happened uses an index as REPLICA IDENTITY.
|
|
368
|
+
Byte1('O') Identifies the following TupleData message as a old tuple. This field is present if the table in which the delete has happened has REPLICA IDENTITY set to FULL.
|
|
369
|
+
TupleData TupleData message part representing the contents of the old tuple or primary key, depending on the previous field.
|
|
370
|
+
|
|
371
|
+
The Delete message may contain either a 'K' message part or an 'O' message part, but never both of them.
|
|
372
|
+
"""
|
|
373
|
+
|
|
374
|
+
byte1: str
|
|
375
|
+
relation_id: int
|
|
376
|
+
message_type: str
|
|
377
|
+
old_tuple: TupleData
|
|
378
|
+
|
|
379
|
+
def decode_buffer(self) -> None:
|
|
380
|
+
if self.byte1 != "D":
|
|
381
|
+
raise ValueError(
|
|
382
|
+
f"first byte in buffer does not match Delete message (expected 'D', got '{self.byte1}'"
|
|
383
|
+
)
|
|
384
|
+
self.relation_id = self.read_int32()
|
|
385
|
+
self.message_type = self.read_utf8()
|
|
386
|
+
# TODO: test with replica identity full
|
|
387
|
+
if self.message_type not in ["K", "O"]:
|
|
388
|
+
raise ValueError(
|
|
389
|
+
f"message type byte is not 'K' or 'O', got: '{self.message_type}'"
|
|
390
|
+
)
|
|
391
|
+
self.old_tuple = self.read_tuple_data()
|
|
392
|
+
|
|
393
|
+
def __repr__(self) -> str:
|
|
394
|
+
return (
|
|
395
|
+
f"DELETE \n\tbyte1: {self.byte1} \n\trelation_id: {self.relation_id} "
|
|
396
|
+
f"\n\tmessage_type: {self.message_type} \n\told_tuple: {self.old_tuple}"
|
|
397
|
+
)
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
class Truncate(PgoutputMessage):
|
|
401
|
+
"""
|
|
402
|
+
Byte1('T') Identifies the message as a truncate message.
|
|
403
|
+
Int32 Number of relations
|
|
404
|
+
Int8 Option bits for TRUNCATE: 1 for CASCADE, 2 for RESTART IDENTITY
|
|
405
|
+
Int32 ID of the relation corresponding to the ID in the relation message. This field is repeated for each relation.
|
|
406
|
+
"""
|
|
407
|
+
|
|
408
|
+
byte1: str
|
|
409
|
+
number_of_relations: int
|
|
410
|
+
option_bits: int
|
|
411
|
+
relation_ids: List[int]
|
|
412
|
+
|
|
413
|
+
def decode_buffer(self) -> None:
|
|
414
|
+
if self.byte1 != "T":
|
|
415
|
+
raise ValueError(
|
|
416
|
+
f"first byte in buffer does not match Truncate message (expected 'T', got '{self.byte1}'"
|
|
417
|
+
)
|
|
418
|
+
self.number_of_relations = self.read_int32()
|
|
419
|
+
self.option_bits = self.read_int8()
|
|
420
|
+
self.relation_ids = []
|
|
421
|
+
for relation in range(self.number_of_relations):
|
|
422
|
+
self.relation_ids.append(self.read_int32())
|
|
423
|
+
|
|
424
|
+
def __repr__(self) -> str:
|
|
425
|
+
return (
|
|
426
|
+
f"TRUNCATE \n\tbyte1: {self.byte1} \n\tn_relations: {self.number_of_relations} "
|
|
427
|
+
f"option_bits: {self.option_bits}, relation_ids: {self.relation_ids}"
|
|
428
|
+
)
|