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
|
@@ -0,0 +1,1405 @@
|
|
|
1
|
+
from typing import (
|
|
2
|
+
Optional,
|
|
3
|
+
Dict,
|
|
4
|
+
Mapping,
|
|
5
|
+
Iterator,
|
|
6
|
+
Union,
|
|
7
|
+
List,
|
|
8
|
+
Sequence,
|
|
9
|
+
Any,
|
|
10
|
+
Tuple,
|
|
11
|
+
FrozenSet,
|
|
12
|
+
NamedTuple,
|
|
13
|
+
)
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
import psycopg2
|
|
17
|
+
from psycopg2.extensions import cursor, connection as ConnectionExt
|
|
18
|
+
from psycopg2.extras import (
|
|
19
|
+
LogicalReplicationConnection,
|
|
20
|
+
ReplicationCursor,
|
|
21
|
+
ReplicationMessage,
|
|
22
|
+
StopReplication,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
import dlt
|
|
26
|
+
from dlt.common import logger
|
|
27
|
+
from dlt.common.typing import TDataItem
|
|
28
|
+
from dlt.common.pendulum import pendulum
|
|
29
|
+
from dlt.common.normalizers.naming.snake_case import NamingConvention
|
|
30
|
+
from dlt.common.schema.typing import (
|
|
31
|
+
TTableSchema,
|
|
32
|
+
TTableSchemaColumns,
|
|
33
|
+
TColumnNames,
|
|
34
|
+
TWriteDisposition,
|
|
35
|
+
DLT_NAME_PREFIX,
|
|
36
|
+
)
|
|
37
|
+
from dlt.common.schema.utils import merge_column
|
|
38
|
+
from dlt.common.configuration.specs.config_section_context import ConfigSectionContext
|
|
39
|
+
from dlt.common.data_writers.escape import escape_postgres_identifier
|
|
40
|
+
|
|
41
|
+
from dlt.extract.items import DataItemWithMeta
|
|
42
|
+
from dlt.extract import DltResource
|
|
43
|
+
|
|
44
|
+
from dlt.sources.config import with_config
|
|
45
|
+
from dlt.sources.credentials import ConnectionStringCredentials
|
|
46
|
+
from dlt.sources.sql_database import (
|
|
47
|
+
sql_table as core_sql_table,
|
|
48
|
+
sql_database as core_sql_database,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
from .schema_types import _to_dlt_column_schema, _to_dlt_val
|
|
52
|
+
from .exceptions import IncompatiblePostgresVersionException
|
|
53
|
+
from .decoders import (
|
|
54
|
+
Begin,
|
|
55
|
+
Relation,
|
|
56
|
+
Insert,
|
|
57
|
+
Update,
|
|
58
|
+
Delete,
|
|
59
|
+
Truncate,
|
|
60
|
+
ColumnData,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
__source_name__ = "pg_replication"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@with_config(
|
|
67
|
+
sections=("sources", "pg_replication"),
|
|
68
|
+
sections_merge_style=ConfigSectionContext.resource_merge_style,
|
|
69
|
+
section_arg_name="slot_name",
|
|
70
|
+
)
|
|
71
|
+
def replication_connection(
|
|
72
|
+
slot_name: str, credentials: ConnectionStringCredentials = dlt.secrets.value
|
|
73
|
+
) -> LogicalReplicationConnection:
|
|
74
|
+
"""Returns a psycopg2 LogicalReplicationConnection to interact with postgres replication functionality.
|
|
75
|
+
|
|
76
|
+
Requires `slot_name` to be passed in order to generate compatible configuraiton section.
|
|
77
|
+
|
|
78
|
+
Raises error if the user does not have the REPLICATION attribute assigned.
|
|
79
|
+
"""
|
|
80
|
+
return _get_rep_conn(credentials)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@with_config(
|
|
84
|
+
sections=("sources", "pg_replication"),
|
|
85
|
+
sections_merge_style=ConfigSectionContext.resource_merge_style,
|
|
86
|
+
section_arg_name="slot_name",
|
|
87
|
+
)
|
|
88
|
+
def init_replication(
|
|
89
|
+
slot_name: str = dlt.config.value,
|
|
90
|
+
pub_name: str = dlt.config.value,
|
|
91
|
+
schema_name: str = dlt.config.value,
|
|
92
|
+
table_names: Optional[Union[str, Sequence[str]]] = dlt.config.value,
|
|
93
|
+
credentials: ConnectionStringCredentials = dlt.secrets.value,
|
|
94
|
+
publish: str = "insert, update, delete",
|
|
95
|
+
persist_snapshots: bool = False,
|
|
96
|
+
include_columns: Optional[Mapping[str, Sequence[str]]] = None,
|
|
97
|
+
columns: Optional[Mapping[str, TTableSchemaColumns]] = None,
|
|
98
|
+
reset: bool = False,
|
|
99
|
+
) -> Optional[Union[DltResource, List[DltResource]]]:
|
|
100
|
+
"""Initializes replication for one, several, or all tables within a schema.
|
|
101
|
+
|
|
102
|
+
Can be called repeatedly with the same `slot_name` and `pub_name`:
|
|
103
|
+
- creates a replication slot and publication with provided names if they do not exist yet
|
|
104
|
+
- skips creation of slot and publication if they already exist (unless`reset` is set to `False`)
|
|
105
|
+
- supports addition of new tables by extending `table_names`
|
|
106
|
+
- removing tables is not supported, i.e. exluding a table from `table_names`
|
|
107
|
+
will not remove it from the publication
|
|
108
|
+
- switching from a table selection to an entire schema is possible by omitting
|
|
109
|
+
the `table_names` argument
|
|
110
|
+
- changing `publish` has no effect (altering the published DML operations is not supported)
|
|
111
|
+
- table snapshots can only be persisted on the first call (because the snapshot
|
|
112
|
+
is exported when the slot is created)
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
slot_name (str): Name of the replication slot to create if it does not exist yet.
|
|
116
|
+
pub_name (str): Name of the publication to create if it does not exist yet.
|
|
117
|
+
schema_name (str): Name of the schema to replicate tables from.
|
|
118
|
+
table_names (Optional[Union[str, Sequence[str]]]): Name(s) of the table(s)
|
|
119
|
+
to include in the publication. If not provided, the whole schema with `schema_name` will be replicated
|
|
120
|
+
(also tables added to the schema after the publication was created). You need superuser privileges
|
|
121
|
+
for the whole schema replication. When specifying individual table names, the database role must
|
|
122
|
+
own the tables if it's not a superuser.
|
|
123
|
+
credentials (ConnectionStringCredentials): Postgres database credentials.
|
|
124
|
+
publish (str): Comma-separated string of DML operations. Can be used to
|
|
125
|
+
control which changes are included in the publication. Allowed operations
|
|
126
|
+
are `insert`, `update`, and `delete`. `truncate` is currently not
|
|
127
|
+
supported—messages of that type are ignored.
|
|
128
|
+
E.g. `publish="insert"` will create a publication that only publishes insert operations.
|
|
129
|
+
persist_snapshots (bool): Whether the table states in the snapshot exported
|
|
130
|
+
during replication slot creation are persisted to tables. If true, a
|
|
131
|
+
snapshot table is created in Postgres for all included tables, and corresponding
|
|
132
|
+
resources (`DltResource` objects) for these tables are created and returned.
|
|
133
|
+
The resources can be used to perform an initial load of all data present
|
|
134
|
+
in the tables at the moment the replication slot got created.
|
|
135
|
+
include_columns (Optional[Dict[str, Sequence[str]]]): Maps table name(s) to
|
|
136
|
+
sequence of names of columns to include in the snapshot table(s).
|
|
137
|
+
Any column not in the sequence is excluded. If not provided, all columns
|
|
138
|
+
are included. For example:
|
|
139
|
+
```
|
|
140
|
+
include_columns={
|
|
141
|
+
"table_x": ["col_a", "col_c"],
|
|
142
|
+
"table_y": ["col_x", "col_y", "col_z"],
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
Argument is only used if `persist_snapshots` is `True`.
|
|
146
|
+
columns (Optional[Dict[str, TTableSchemaColumns]]): Maps
|
|
147
|
+
table name(s) to column hints to apply on the snapshot table resource(s).
|
|
148
|
+
For example:
|
|
149
|
+
```
|
|
150
|
+
columns={
|
|
151
|
+
"table_x": {"col_a": {"data_type": "json"}},
|
|
152
|
+
"table_y": {"col_y": {"precision": 32}},
|
|
153
|
+
}
|
|
154
|
+
```
|
|
155
|
+
Argument is only used if `persist_snapshots` is `True`.
|
|
156
|
+
reset (bool): If set to True, the existing slot and publication are dropped
|
|
157
|
+
and recreated. Has no effect if a slot and publication with the provided
|
|
158
|
+
names do not yet exist.
|
|
159
|
+
|
|
160
|
+
Returns:
|
|
161
|
+
- None if `persist_snapshots` is `False`
|
|
162
|
+
- a `DltResource` object or a list of `DltResource` objects for the snapshot
|
|
163
|
+
table(s) if `persist_snapshots` is `True` and the replication slot did not yet exist
|
|
164
|
+
"""
|
|
165
|
+
if isinstance(table_names, str):
|
|
166
|
+
table_names = [table_names]
|
|
167
|
+
|
|
168
|
+
rep_conn = _get_rep_conn(credentials)
|
|
169
|
+
try:
|
|
170
|
+
with rep_conn.cursor() as cur:
|
|
171
|
+
if reset:
|
|
172
|
+
drop_replication_slot(slot_name, cur)
|
|
173
|
+
drop_publication(pub_name, cur)
|
|
174
|
+
create_publication(pub_name, cur, publish)
|
|
175
|
+
if table_names is None:
|
|
176
|
+
add_schema_to_publication(schema_name, pub_name, cur)
|
|
177
|
+
else:
|
|
178
|
+
add_tables_to_publication(table_names, schema_name, pub_name, cur)
|
|
179
|
+
|
|
180
|
+
slot = create_replication_slot(slot_name, cur)
|
|
181
|
+
if persist_snapshots:
|
|
182
|
+
if slot is None:
|
|
183
|
+
raise RuntimeError(
|
|
184
|
+
f"Cannot create snapshots because slot {slot_name} is already created."
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
# get list of tables via sql_database if not provided
|
|
188
|
+
if table_names is None:
|
|
189
|
+
# do not include dlt tables
|
|
190
|
+
table_names = [
|
|
191
|
+
table_name
|
|
192
|
+
for table_name in core_sql_database(
|
|
193
|
+
credentials, schema=schema_name, reflection_level="minimal"
|
|
194
|
+
).resources.keys()
|
|
195
|
+
if not table_name.lower().startswith(DLT_NAME_PREFIX)
|
|
196
|
+
]
|
|
197
|
+
|
|
198
|
+
# need separate session to read the snapshot: https://stackoverflow.com/q/75852587
|
|
199
|
+
with _get_conn(credentials) as conn:
|
|
200
|
+
with conn.cursor() as cur_snap:
|
|
201
|
+
# set the snapshot to the snaphost of the newly created replication slot
|
|
202
|
+
cur_snap.execute(
|
|
203
|
+
f"""
|
|
204
|
+
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;
|
|
205
|
+
SET TRANSACTION SNAPSHOT '{slot["snapshot_name"]}';
|
|
206
|
+
"""
|
|
207
|
+
)
|
|
208
|
+
snapshot_tables = [
|
|
209
|
+
(
|
|
210
|
+
table_name,
|
|
211
|
+
persist_snapshot_table(
|
|
212
|
+
snapshot_name=slot["snapshot_name"],
|
|
213
|
+
table_name=table_name,
|
|
214
|
+
schema_name=schema_name,
|
|
215
|
+
cur=cur_snap,
|
|
216
|
+
include_columns=(
|
|
217
|
+
None
|
|
218
|
+
if include_columns is None
|
|
219
|
+
else include_columns.get(table_name)
|
|
220
|
+
),
|
|
221
|
+
),
|
|
222
|
+
_get_pk(cur_snap, table_name, schema_name),
|
|
223
|
+
)
|
|
224
|
+
for table_name in table_names
|
|
225
|
+
]
|
|
226
|
+
# commit all tables before creating resources that will reflect them
|
|
227
|
+
conn.commit()
|
|
228
|
+
snapshot_table_resources = [
|
|
229
|
+
snapshot_table_resource(
|
|
230
|
+
snapshot_table_name=snapshot_table_name,
|
|
231
|
+
schema_name=schema_name,
|
|
232
|
+
table_name=table_name,
|
|
233
|
+
write_disposition="append" if publish == "insert" else "merge",
|
|
234
|
+
columns=None if columns is None else columns.get(table_name),
|
|
235
|
+
primary_key=primary_key,
|
|
236
|
+
credentials=credentials,
|
|
237
|
+
)
|
|
238
|
+
for table_name, snapshot_table_name, primary_key in snapshot_tables
|
|
239
|
+
]
|
|
240
|
+
if len(snapshot_table_resources) == 1:
|
|
241
|
+
return snapshot_table_resources[0]
|
|
242
|
+
return snapshot_table_resources
|
|
243
|
+
except Exception:
|
|
244
|
+
rep_conn.rollback()
|
|
245
|
+
raise
|
|
246
|
+
else:
|
|
247
|
+
rep_conn.commit()
|
|
248
|
+
finally:
|
|
249
|
+
rep_conn.close()
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
@dlt.sources.config.with_config(sections=("sources", "pg_replication"))
|
|
254
|
+
def get_pg_version(
|
|
255
|
+
cur: cursor = None,
|
|
256
|
+
credentials: ConnectionStringCredentials = dlt.secrets.value,
|
|
257
|
+
) -> int:
|
|
258
|
+
"""Returns Postgres server version as int."""
|
|
259
|
+
if cur is not None:
|
|
260
|
+
return cur.connection.server_version
|
|
261
|
+
with _get_conn(credentials) as conn:
|
|
262
|
+
return conn.server_version
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def create_publication(
|
|
266
|
+
name: str,
|
|
267
|
+
cur: cursor,
|
|
268
|
+
publish: str = "insert, update, delete",
|
|
269
|
+
) -> None:
|
|
270
|
+
"""Creates a publication for logical replication if it doesn't exist yet.
|
|
271
|
+
|
|
272
|
+
An existing publication is left as is, except that on PG 18+ it is made to publish
|
|
273
|
+
stored generated columns.
|
|
274
|
+
Raises error if the user does not have the CREATE privilege for the database.
|
|
275
|
+
"""
|
|
276
|
+
esc_name = escape_postgres_identifier(name)
|
|
277
|
+
# PG 18: unpublished stored generated columns make FULL-identity tables un-updatable.
|
|
278
|
+
pg18 = cur.connection.server_version >= 180000
|
|
279
|
+
gencols = ", publish_generated_columns = stored" if pg18 else ""
|
|
280
|
+
try:
|
|
281
|
+
cur.execute(f"CREATE PUBLICATION {esc_name} WITH (publish = '{publish}'{gencols});")
|
|
282
|
+
logger.info(
|
|
283
|
+
f"Successfully created publication {esc_name} with publish = '{publish}'."
|
|
284
|
+
)
|
|
285
|
+
except psycopg2.errors.DuplicateObject: # the publication already exists
|
|
286
|
+
logger.info(f'Publication "{name}" already exists.')
|
|
287
|
+
if pg18:
|
|
288
|
+
cur.execute("SELECT pubgencols FROM pg_publication WHERE pubname = %s", (name,))
|
|
289
|
+
if cur.fetchone()[0] != "s":
|
|
290
|
+
cur.execute(f"ALTER PUBLICATION {esc_name} SET (publish_generated_columns = stored);")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def add_table_to_publication(
|
|
294
|
+
table_name: str,
|
|
295
|
+
schema_name: str,
|
|
296
|
+
pub_name: str,
|
|
297
|
+
cur: cursor,
|
|
298
|
+
) -> None:
|
|
299
|
+
"""Adds a table to a publication for logical replication.
|
|
300
|
+
|
|
301
|
+
Does nothing if the table is already a member of the publication.
|
|
302
|
+
Raises error if the user is not owner of the table.
|
|
303
|
+
"""
|
|
304
|
+
qual_name = _make_qualified_table_name(table_name, schema_name)
|
|
305
|
+
esc_pub_name = escape_postgres_identifier(pub_name)
|
|
306
|
+
try:
|
|
307
|
+
cur.execute(f"ALTER PUBLICATION {esc_pub_name} ADD TABLE {qual_name};")
|
|
308
|
+
logger.info(
|
|
309
|
+
f"Successfully added table {qual_name} to publication {esc_pub_name}."
|
|
310
|
+
)
|
|
311
|
+
except psycopg2.errors.DuplicateObject:
|
|
312
|
+
logger.info(
|
|
313
|
+
f"Table {qual_name} is already a member of publication {esc_pub_name}."
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def add_tables_to_publication(
|
|
318
|
+
table_names: Union[str, Sequence[str]],
|
|
319
|
+
schema_name: str,
|
|
320
|
+
pub_name: str,
|
|
321
|
+
cur: cursor,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Adds one or multiple tables to a publication for logical replication.
|
|
324
|
+
|
|
325
|
+
Calls `add_table_to_publication` for each table in `table_names`.
|
|
326
|
+
"""
|
|
327
|
+
if isinstance(table_names, str):
|
|
328
|
+
table_names = table_names
|
|
329
|
+
for table_name in table_names:
|
|
330
|
+
add_table_to_publication(table_name, schema_name, pub_name, cur)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def add_schema_to_publication(
|
|
334
|
+
schema_name: str,
|
|
335
|
+
pub_name: str,
|
|
336
|
+
cur: cursor,
|
|
337
|
+
) -> None:
|
|
338
|
+
"""Adds a schema to a publication for logical replication if the schema is not a member yet.
|
|
339
|
+
|
|
340
|
+
Raises error if the user is not a superuser.
|
|
341
|
+
"""
|
|
342
|
+
if (version := get_pg_version(cur)) < 150000:
|
|
343
|
+
raise IncompatiblePostgresVersionException(
|
|
344
|
+
f"Cannot add schema to publication because the Postgres server version {version} is too low."
|
|
345
|
+
" Adding schemas to a publication is only supported for Postgres version 15 or higher."
|
|
346
|
+
" Upgrade your Postgres server version or set the `table_names` argument to explicitly specify table names."
|
|
347
|
+
)
|
|
348
|
+
esc_schema_name = escape_postgres_identifier(schema_name)
|
|
349
|
+
esc_pub_name = escape_postgres_identifier(pub_name)
|
|
350
|
+
try:
|
|
351
|
+
cur.execute(
|
|
352
|
+
f"ALTER PUBLICATION {esc_pub_name} ADD TABLES IN SCHEMA {esc_schema_name};"
|
|
353
|
+
)
|
|
354
|
+
logger.info(
|
|
355
|
+
f"Successfully added schema {esc_schema_name} to publication {esc_pub_name}."
|
|
356
|
+
)
|
|
357
|
+
except psycopg2.errors.DuplicateObject:
|
|
358
|
+
logger.info(
|
|
359
|
+
f"Schema {esc_schema_name} is already a member of publication {esc_pub_name}."
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def create_replication_slot( # type: ignore[return]
|
|
364
|
+
name: str, cur: ReplicationCursor, output_plugin: str = "pgoutput"
|
|
365
|
+
) -> Optional[Dict[str, str]]:
|
|
366
|
+
"""Creates a replication slot if it doesn't exist yet."""
|
|
367
|
+
try:
|
|
368
|
+
cur.create_replication_slot(name, output_plugin=output_plugin)
|
|
369
|
+
logger.info(f'Successfully created replication slot "{name}".')
|
|
370
|
+
result = cur.fetchone()
|
|
371
|
+
return {
|
|
372
|
+
"slot_name": result[0],
|
|
373
|
+
"consistent_point": result[1],
|
|
374
|
+
"snapshot_name": result[2],
|
|
375
|
+
"output_plugin": result[3],
|
|
376
|
+
}
|
|
377
|
+
except psycopg2.errors.DuplicateObject: # the replication slot already exists
|
|
378
|
+
logger.info(
|
|
379
|
+
f'Replication slot "{name}" cannot be created because it already exists.'
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def drop_replication_slot(name: str, cur: ReplicationCursor) -> None:
|
|
384
|
+
"""Drops a replication slot if it exists."""
|
|
385
|
+
try:
|
|
386
|
+
cur.drop_replication_slot(name)
|
|
387
|
+
logger.info(f'Successfully dropped replication slot "{name}".')
|
|
388
|
+
except psycopg2.errors.UndefinedObject: # the replication slot does not exist
|
|
389
|
+
logger.info(
|
|
390
|
+
f'Replication slot "{name}" cannot be dropped because it does not exist.'
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def drop_publication(name: str, cur: ReplicationCursor) -> None:
|
|
395
|
+
"""Drops a publication if it exists."""
|
|
396
|
+
esc_name = escape_postgres_identifier(name)
|
|
397
|
+
try:
|
|
398
|
+
cur.execute(f"DROP PUBLICATION {esc_name};")
|
|
399
|
+
logger.info(f"Successfully dropped publication {esc_name}.")
|
|
400
|
+
except psycopg2.errors.UndefinedObject: # the publication does not exist
|
|
401
|
+
logger.info(
|
|
402
|
+
f"Publication {esc_name} cannot be dropped because it does not exist."
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def persist_snapshot_table(
|
|
407
|
+
snapshot_name: str,
|
|
408
|
+
table_name: str,
|
|
409
|
+
schema_name: str,
|
|
410
|
+
cur: cursor,
|
|
411
|
+
include_columns: Optional[Sequence[str]] = None,
|
|
412
|
+
) -> str:
|
|
413
|
+
"""Persists exported snapshot table state.
|
|
414
|
+
|
|
415
|
+
Reads snapshot table content and copies it into new table.
|
|
416
|
+
"""
|
|
417
|
+
col_str = "*"
|
|
418
|
+
if include_columns is not None:
|
|
419
|
+
col_str = ", ".join(map(escape_postgres_identifier, include_columns))
|
|
420
|
+
# make sure to shorten identifier
|
|
421
|
+
naming = NamingConvention(63)
|
|
422
|
+
# name must start with _dlt so we skip this table when replicating
|
|
423
|
+
snapshot_table_name = naming.normalize_table_identifier(
|
|
424
|
+
f"_dlt_{table_name}_s_{snapshot_name}"
|
|
425
|
+
)
|
|
426
|
+
snapshot_qual_name = _make_qualified_table_name(snapshot_table_name, schema_name)
|
|
427
|
+
qual_name = _make_qualified_table_name(table_name, schema_name)
|
|
428
|
+
cur.execute(
|
|
429
|
+
f"""
|
|
430
|
+
CREATE TABLE {snapshot_qual_name} AS SELECT {col_str} FROM {qual_name};
|
|
431
|
+
"""
|
|
432
|
+
)
|
|
433
|
+
logger.info(f"Successfully persisted snapshot table state in {snapshot_qual_name}.")
|
|
434
|
+
return snapshot_table_name
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def snapshot_table_resource(
|
|
438
|
+
snapshot_table_name: str,
|
|
439
|
+
schema_name: str,
|
|
440
|
+
table_name: str,
|
|
441
|
+
primary_key: TColumnNames,
|
|
442
|
+
write_disposition: TWriteDisposition,
|
|
443
|
+
columns: TTableSchemaColumns = None,
|
|
444
|
+
credentials: ConnectionStringCredentials = dlt.secrets.value,
|
|
445
|
+
) -> DltResource:
|
|
446
|
+
"""Returns a resource for a persisted snapshot table.
|
|
447
|
+
|
|
448
|
+
Can be used to perform an initial load of the table, so all data that
|
|
449
|
+
existed in the table prior to initializing replication is also captured.
|
|
450
|
+
"""
|
|
451
|
+
resource: DltResource = core_sql_table(
|
|
452
|
+
credentials=credentials,
|
|
453
|
+
table=snapshot_table_name,
|
|
454
|
+
schema=schema_name,
|
|
455
|
+
reflection_level="full_with_precision",
|
|
456
|
+
)
|
|
457
|
+
resource.apply_hints(
|
|
458
|
+
table_name=table_name,
|
|
459
|
+
write_disposition=write_disposition,
|
|
460
|
+
columns=columns,
|
|
461
|
+
primary_key=primary_key,
|
|
462
|
+
)
|
|
463
|
+
return resource
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def get_max_lsn(
|
|
467
|
+
slot_name: str,
|
|
468
|
+
options: Dict[str, str],
|
|
469
|
+
credentials: ConnectionStringCredentials,
|
|
470
|
+
) -> Optional[int]:
|
|
471
|
+
"""Returns maximum Log Sequence Number (LSN) in replication slot.
|
|
472
|
+
|
|
473
|
+
Returns None if the replication slot is empty.
|
|
474
|
+
Does not consume the slot, i.e. messages are not flushed.
|
|
475
|
+
Raises error if the replication slot or publication does not exist.
|
|
476
|
+
"""
|
|
477
|
+
# comma-separated value string
|
|
478
|
+
options_str = ", ".join(
|
|
479
|
+
f"'{x}'" for xs in list(map(list, options.items())) for x in xs
|
|
480
|
+
)
|
|
481
|
+
with _get_conn(credentials) as conn:
|
|
482
|
+
with conn.cursor() as cur:
|
|
483
|
+
cur.execute(
|
|
484
|
+
"SELECT MAX(lsn) - '0/0' AS max_lsn " # subtract '0/0' to convert pg_lsn type to int (https://stackoverflow.com/a/73738472)
|
|
485
|
+
f"FROM pg_logical_slot_peek_binary_changes('{slot_name}', NULL, NULL, {options_str});"
|
|
486
|
+
)
|
|
487
|
+
lsn: int = cur.fetchone()[0]
|
|
488
|
+
return lsn
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def get_pub_ops(
|
|
492
|
+
pub_name: str,
|
|
493
|
+
cur: cursor,
|
|
494
|
+
) -> Dict[str, bool]:
|
|
495
|
+
"""Returns dictionary of DML operations and their publish status."""
|
|
496
|
+
|
|
497
|
+
cur.execute(
|
|
498
|
+
f"""
|
|
499
|
+
SELECT pubinsert, pubupdate, pubdelete, pubtruncate
|
|
500
|
+
FROM pg_publication WHERE pubname = '{pub_name}'
|
|
501
|
+
"""
|
|
502
|
+
)
|
|
503
|
+
result = cur.fetchone()
|
|
504
|
+
# credentials.connection.commit()
|
|
505
|
+
if result is None:
|
|
506
|
+
raise ValueError(f'Publication "{pub_name}" does not exist.')
|
|
507
|
+
return {
|
|
508
|
+
"insert": result[0],
|
|
509
|
+
"update": result[1],
|
|
510
|
+
"delete": result[2],
|
|
511
|
+
"truncate": result[3],
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def apply_true_primary_key(
|
|
516
|
+
columns: TTableSchemaColumns, pk: TColumnNames
|
|
517
|
+
) -> None:
|
|
518
|
+
"""Overrides per-column primary_key hints with the table's actual primary key (local
|
|
519
|
+
patch, #2851 — see process_relation: replica-identity flags are not the PK under
|
|
520
|
+
REPLICA IDENTITY FULL, and a whole-row merge key turns updates into duplicates)."""
|
|
521
|
+
pk_set = {pk} if isinstance(pk, str) else set(pk)
|
|
522
|
+
for name, schema in columns.items():
|
|
523
|
+
schema["primary_key"] = name in pk_set
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def resolve_unchanged_toast(
|
|
527
|
+
new: List[ColumnData],
|
|
528
|
+
old: Optional[List[ColumnData]],
|
|
529
|
+
table_name: str,
|
|
530
|
+
) -> List[ColumnData]:
|
|
531
|
+
"""Substitutes unchanged-TOAST columns in an UPDATE's new tuple (local patch, #2851 review).
|
|
532
|
+
|
|
533
|
+
pgoutput omits the value of a TOASTed column the UPDATE did not touch, marking it
|
|
534
|
+
category 'u' instead — upstream's `_to_dlt_val` then raises and the drain dies without
|
|
535
|
+
advancing the slot. When the table has REPLICA IDENTITY FULL the old tuple carries the
|
|
536
|
+
real value (unchanged, by definition), so splice it in. Without an old tuple there is
|
|
537
|
+
no way to recover the value in a merge-shaped load — fail loudly with the remedy
|
|
538
|
+
rather than silently NULLing operator data.
|
|
539
|
+
"""
|
|
540
|
+
if not any(c.col_data_category == "u" for c in new):
|
|
541
|
+
return new
|
|
542
|
+
if old is None or len(old) != len(new):
|
|
543
|
+
raise ValueError(
|
|
544
|
+
f"table {table_name!r}: UPDATE carries unchanged-TOAST column(s) but no full old"
|
|
545
|
+
" tuple to recover them from. Set REPLICA IDENTITY FULL on the table"
|
|
546
|
+
f' (ALTER TABLE "{table_name}" REPLICA IDENTITY FULL) — this drainer enforces this'
|
|
547
|
+
" each tick; re-run it or apply the ALTER by hand."
|
|
548
|
+
)
|
|
549
|
+
return [o if n.col_data_category == "u" else n for n, o in zip(new, old)]
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def lsn_int_to_hex(lsn: int) -> str:
|
|
553
|
+
"""Convert integer LSN to postgres' hexadecimal representation."""
|
|
554
|
+
# https://stackoverflow.com/questions/66797767/lsn-external-representation.
|
|
555
|
+
return f"{lsn >> 32 & 4294967295:X}/{lsn & 4294967295:08X}"
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def advance_slot(
|
|
559
|
+
upto_lsn: int,
|
|
560
|
+
slot_name: str,
|
|
561
|
+
credentials: ConnectionStringCredentials,
|
|
562
|
+
) -> None:
|
|
563
|
+
"""Advances position in the replication slot.
|
|
564
|
+
|
|
565
|
+
Flushes all messages upto (and including) the message with LSN = `upto_lsn`.
|
|
566
|
+
This function is used as alternative to psycopg2's `send_feedback` method, because
|
|
567
|
+
the behavior of that method seems odd when used outside of `consume_stream`.
|
|
568
|
+
"""
|
|
569
|
+
if upto_lsn != 0:
|
|
570
|
+
with _get_conn(credentials) as conn:
|
|
571
|
+
with conn.cursor() as cur:
|
|
572
|
+
cur.execute(
|
|
573
|
+
f"SELECT * FROM pg_replication_slot_advance('{slot_name}', '{lsn_int_to_hex(upto_lsn)}');"
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _get_conn(
|
|
578
|
+
credentials: ConnectionStringCredentials,
|
|
579
|
+
connection_factory: Optional[Any] = None,
|
|
580
|
+
) -> ConnectionExt:
|
|
581
|
+
"""Returns a psycopg2 connection to interact with postgres."""
|
|
582
|
+
conn = psycopg2.connect(
|
|
583
|
+
dsn=credentials.to_native_representation(),
|
|
584
|
+
connection_factory=connection_factory,
|
|
585
|
+
)
|
|
586
|
+
if connection_factory is not LogicalReplicationConnection:
|
|
587
|
+
conn.autocommit = False
|
|
588
|
+
return conn # type: ignore[no-any-return]
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def _get_rep_conn(
|
|
592
|
+
credentials: ConnectionStringCredentials,
|
|
593
|
+
) -> LogicalReplicationConnection:
|
|
594
|
+
"""Returns a psycopg2 LogicalReplicationConnection to interact with postgres replication functionality.
|
|
595
|
+
|
|
596
|
+
Raises error if the user does not have the REPLICATION attribute assigned.
|
|
597
|
+
"""
|
|
598
|
+
return _get_conn(credentials, LogicalReplicationConnection) # type: ignore[return-value]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _make_qualified_table_name(table_name: str, schema_name: str) -> str:
|
|
602
|
+
"""Escapes and combines a schema and table name."""
|
|
603
|
+
return (
|
|
604
|
+
escape_postgres_identifier(schema_name)
|
|
605
|
+
+ "."
|
|
606
|
+
+ escape_postgres_identifier(table_name)
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
def _get_pk(
|
|
611
|
+
cur: cursor,
|
|
612
|
+
table_name: str,
|
|
613
|
+
schema_name: str,
|
|
614
|
+
) -> Optional[TColumnNames]:
|
|
615
|
+
"""Returns primary key column(s) for postgres table.
|
|
616
|
+
|
|
617
|
+
Returns None if no primary key columns exist.
|
|
618
|
+
"""
|
|
619
|
+
qual_name = _make_qualified_table_name(table_name, schema_name)
|
|
620
|
+
# https://wiki.postgresql.org/wiki/Retrieve_primary_key_columns
|
|
621
|
+
cur.execute(
|
|
622
|
+
f"""
|
|
623
|
+
SELECT a.attname
|
|
624
|
+
FROM pg_index i
|
|
625
|
+
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
|
|
626
|
+
WHERE i.indrelid = '{qual_name}'::regclass
|
|
627
|
+
AND i.indisprimary;
|
|
628
|
+
"""
|
|
629
|
+
)
|
|
630
|
+
result = [tup[0] for tup in cur.fetchall()]
|
|
631
|
+
if len(result) == 0:
|
|
632
|
+
return None
|
|
633
|
+
elif len(result) == 1:
|
|
634
|
+
return result[0] # type: ignore[no-any-return]
|
|
635
|
+
return result
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
class ReviewedLayout(NamedTuple):
|
|
639
|
+
"""Local patch (#4346, Codex round 17): a relation's published columns exactly as a
|
|
640
|
+
pgoutput Relation message describes them, plus what the message cannot carry.
|
|
641
|
+
|
|
642
|
+
`columns`: ((attnum, name, type oid, typmod, replica-identity key flag), ...) in attnum
|
|
643
|
+
order — the message lists the same columns in the same order, minus the attnum.
|
|
644
|
+
`hidden`: attnums of live columns pgoutput does not publish (generated columns before
|
|
645
|
+
PG 18, virtual ones on 18). The message omits them, so a hidden column that becomes
|
|
646
|
+
published (`ALTER COLUMN ... DROP EXPRESSION`) is caught by the catalogue, not the message."""
|
|
647
|
+
columns: Tuple[Tuple[int, str, int, int, int], ...]
|
|
648
|
+
hidden: FrozenSet[int] = frozenset()
|
|
649
|
+
|
|
650
|
+
def described(self) -> List[Tuple[str, int, int, int]]:
|
|
651
|
+
"""What a Relation message of this layout must say, column by column."""
|
|
652
|
+
return [(n, t, m, k) for _a, n, t, m, k in self.columns]
|
|
653
|
+
|
|
654
|
+
def to_json(self) -> Dict[str, Any]:
|
|
655
|
+
return {"columns": [list(c) for c in self.columns], "hidden": sorted(self.hidden)}
|
|
656
|
+
|
|
657
|
+
@classmethod
|
|
658
|
+
def from_json(cls, raw: Any) -> Optional["ReviewedLayout"]:
|
|
659
|
+
"""None for anything but the full description (an older [[attnum, name], ...] row
|
|
660
|
+
cannot be compared exactly, so the relation has no reviewed layout: quarantined)."""
|
|
661
|
+
if not isinstance(raw, dict) or "columns" not in raw:
|
|
662
|
+
return None
|
|
663
|
+
return cls(tuple((int(a), str(n), int(t), int(m), int(k)) for a, n, t, m, k in raw["columns"]),
|
|
664
|
+
frozenset(int(a) for a in raw.get("hidden", ())))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def published_shape(cur: Any, relid: int) -> Tuple[ReviewedLayout, bool]:
|
|
668
|
+
"""Local patch (#4346): this relation's `ReviewedLayout` in whatever snapshot `cur` reads,
|
|
669
|
+
and whether the table has EVER dropped a column (`attisdropped` rows stay for good).
|
|
670
|
+
Stored generated columns are published on PG 18 (publication.py), virtual ones never.
|
|
671
|
+
The key flag is pgoutput's: every column under REPLICA IDENTITY FULL, the identity index's
|
|
672
|
+
columns under DEFAULT (the primary key) or USING INDEX, none otherwise.
|
|
673
|
+
Works on a psycopg2 or a psycopg 3 cursor."""
|
|
674
|
+
cur.execute("select current_setting('server_version_num')::int >= 180000")
|
|
675
|
+
pg18 = bool(cur.fetchone()[0])
|
|
676
|
+
published = "a.attgenerated in ('', 's')" if pg18 else "a.attgenerated = ''"
|
|
677
|
+
cur.execute(
|
|
678
|
+
f"""select a.attnum, a.attname, a.atttypid::bigint, a.atttypmod, a.attisdropped,
|
|
679
|
+
({published}) as published,
|
|
680
|
+
case when c.relreplident = 'f' then 1
|
|
681
|
+
when exists (select 1 from pg_index i
|
|
682
|
+
where i.indrelid = c.oid and a.attnum = any(i.indkey)
|
|
683
|
+
and ((c.relreplident = 'd' and i.indisprimary)
|
|
684
|
+
or (c.relreplident = 'i' and i.indisreplident)))
|
|
685
|
+
then 1 else 0 end
|
|
686
|
+
from pg_attribute a join pg_class c on c.oid = a.attrelid
|
|
687
|
+
where a.attrelid = %s and a.attnum > 0 order by a.attnum""", (relid,))
|
|
688
|
+
rows = cur.fetchall()
|
|
689
|
+
columns = tuple((int(a), n, int(t), int(m), int(k))
|
|
690
|
+
for a, n, t, m, dropped, pub, k in rows if not dropped and pub)
|
|
691
|
+
hidden = frozenset(int(a) for a, _n, _t, _m, dropped, pub, _k in rows
|
|
692
|
+
if not dropped and not pub)
|
|
693
|
+
return ReviewedLayout(columns, hidden), any(r[4] for r in rows)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
@dataclass
|
|
697
|
+
class ItemGenerator:
|
|
698
|
+
credentials: ConnectionStringCredentials
|
|
699
|
+
slot_name: str
|
|
700
|
+
options: Dict[str, str]
|
|
701
|
+
upto_lsn: int
|
|
702
|
+
start_lsn: int = 0
|
|
703
|
+
target_batch_size: int = 1000
|
|
704
|
+
include_columns: Optional[Dict[str, Sequence[str]]] = (None,) # type: ignore[assignment]
|
|
705
|
+
columns: Optional[Dict[str, TTableSchemaColumns]] = (None,) # type: ignore[assignment]
|
|
706
|
+
# (schema, table) -> primary key columns (#3079): saves one catalog round-trip per
|
|
707
|
+
# Relation message and keeps relation identity schema-qualified.
|
|
708
|
+
keys_by_relation: Optional[Dict[Any, Sequence[str]]] = None
|
|
709
|
+
# (schema, table) -> key policy; "append_only" turns the CDC merge key into `lsn`.
|
|
710
|
+
policies_by_relation: Optional[Dict[Any, str]] = None
|
|
711
|
+
# (schema, table) -> boundary LSN of that relation's current baseline (#3079 review): a
|
|
712
|
+
# transaction with commit LSN below it is already in the baseline and is dropped here.
|
|
713
|
+
baseline_lsn_by_relation: Optional[Dict[Any, int]] = None
|
|
714
|
+
# Local patch (#4346): (schema, table) -> columns that never leave the CDC process.
|
|
715
|
+
exclude_columns: Optional[Dict[Any, Sequence[str]]] = None
|
|
716
|
+
# Local patch (#4346): relation OID -> denied columns. SHARED and MUTATED (the caller passes
|
|
717
|
+
# one dict to every batch of a drain): a deny follows the table's OID through a rename.
|
|
718
|
+
exclude_columns_by_relid: Optional[Dict[int, Any]] = None
|
|
719
|
+
# Local patch (#4346, Sonnet F1): (schema, table) -> the columns its last reviewed signature
|
|
720
|
+
# names. On a relation that carries a deny, any other column is withheld until reviewed.
|
|
721
|
+
approved_columns: Optional[Dict[Any, Any]] = None
|
|
722
|
+
# relation OID -> reviewed columns (the signature's `oid`), and relation OID -> denied
|
|
723
|
+
# attnums. Both SHARED across the drain's batches and follow a table rename.
|
|
724
|
+
approved_by_relid: Optional[Dict[int, Any]] = None
|
|
725
|
+
exclude_attnums_by_relid: Optional[Dict[int, Any]] = None
|
|
726
|
+
# relation OID -> `ReviewedLayout` (#4346), recorded by the relation's baseline under its
|
|
727
|
+
# locks. A description of a deny-carrying relation must match it exactly. Read-only.
|
|
728
|
+
reviewed_layout_by_relid: Optional[Dict[int, Any]] = None
|
|
729
|
+
# OIDs whose deny was bound at a trusted moment (run start, or a baseline under its locks).
|
|
730
|
+
# None = not tracked (library use). SHARED, not mutated.
|
|
731
|
+
bound_relids: Optional[Any] = None
|
|
732
|
+
# OIDs whose changes are skipped (quarantined) — seeded with the persisted marks, SHARED and
|
|
733
|
+
# MUTATED, so a quarantine holds across every batch of the drain.
|
|
734
|
+
quarantined_relids: Optional[set] = None
|
|
735
|
+
last_commit_lsn: Optional[int] = field(default=None, init=False)
|
|
736
|
+
generated_all: bool = False
|
|
737
|
+
# TRUNCATEs seen in this batch, in WAL order: {namespace, table, lsn, commit_ts}.
|
|
738
|
+
truncates: List[Dict[str, Any]] = field(default_factory=list, init=False)
|
|
739
|
+
|
|
740
|
+
def collect(self) -> Dict[str, List[Union[TDataItem, DataItemWithMeta]]]:
|
|
741
|
+
"""Consumes one batch and returns it grouped by SOURCE SCHEMA (#3079): two schemas
|
|
742
|
+
may hold the same table name, and each source schema loads through its own pipeline."""
|
|
743
|
+
consumer = self._consume()
|
|
744
|
+
grouped: Dict[str, List[Union[TDataItem, DataItemWithMeta]]] = {}
|
|
745
|
+
# One group per relation DESCRIPTION, in WAL order (#4345): a relation renamed or
|
|
746
|
+
# altered mid-batch has a group per description, each with its own name and hints.
|
|
747
|
+
for _rel_id, schema, data_items in consumer.groups:
|
|
748
|
+
table_name = schema["name"]
|
|
749
|
+
namespace = schema.get("namespace") or ""
|
|
750
|
+
if table_name.lower().startswith(DLT_NAME_PREFIX):
|
|
751
|
+
continue # dlt internal table on the source
|
|
752
|
+
if len(data_items) < 2:
|
|
753
|
+
# Local patch (#4338): no rows, only the hints item — every change of this
|
|
754
|
+
# relation in the batch was below its baseline boundary (or truncated away).
|
|
755
|
+
# Loading the hints alone records `lsn`/`deleted_ts` in the dlt schema without
|
|
756
|
+
# creating them, and the relation's next real merge then fails on `lsn`.
|
|
757
|
+
continue
|
|
758
|
+
bucket = grouped.setdefault(namespace, [])
|
|
759
|
+
bucket.append(data_items[0]) # meta item with column hints only, no data
|
|
760
|
+
bucket.append(dlt.mark.with_table_name(data_items[1:], table_name))
|
|
761
|
+
return grouped
|
|
762
|
+
|
|
763
|
+
def _consume(self) -> "MessageConsumer":
|
|
764
|
+
"""Yields replication messages from MessageConsumer.
|
|
765
|
+
|
|
766
|
+
Starts replication of messages published by the publication from the replication slot.
|
|
767
|
+
Maintains LSN of last consumed Commit message in object state.
|
|
768
|
+
Does not advance the slot.
|
|
769
|
+
"""
|
|
770
|
+
# Local patch (#3079 review): `cur` must exist before the try, or a failure to OPEN the
|
|
771
|
+
# replication connection — the slot already having a consumer, or an unreachable source —
|
|
772
|
+
# dies in `finally` with `UnboundLocalError: cur` and hides the real error from the log.
|
|
773
|
+
cur = None
|
|
774
|
+
try:
|
|
775
|
+
cur = _get_rep_conn(self.credentials).cursor()
|
|
776
|
+
pub_ops = get_pub_ops(self.options["publication_names"], cur)
|
|
777
|
+
cur.start_replication(
|
|
778
|
+
slot_name=self.slot_name,
|
|
779
|
+
start_lsn=self.start_lsn,
|
|
780
|
+
decode=False,
|
|
781
|
+
options=self.options,
|
|
782
|
+
)
|
|
783
|
+
consumer = MessageConsumer(
|
|
784
|
+
upto_lsn=self.upto_lsn,
|
|
785
|
+
pub_ops=pub_ops,
|
|
786
|
+
target_batch_size=self.target_batch_size,
|
|
787
|
+
include_columns=self.include_columns,
|
|
788
|
+
columns=self.columns,
|
|
789
|
+
credentials=self.credentials, # for true-PK resolution (#2851 patch)
|
|
790
|
+
keys_by_relation=self.keys_by_relation,
|
|
791
|
+
policies_by_relation=self.policies_by_relation,
|
|
792
|
+
baseline_lsn_by_relation=self.baseline_lsn_by_relation,
|
|
793
|
+
exclude_columns=self.exclude_columns, # Local patch (#4346)
|
|
794
|
+
exclude_columns_by_relid=self.exclude_columns_by_relid,
|
|
795
|
+
approved_columns=self.approved_columns,
|
|
796
|
+
approved_by_relid=self.approved_by_relid,
|
|
797
|
+
exclude_attnums_by_relid=self.exclude_attnums_by_relid,
|
|
798
|
+
reviewed_layout_by_relid=self.reviewed_layout_by_relid,
|
|
799
|
+
bound_relids=self.bound_relids,
|
|
800
|
+
quarantined_relids=self.quarantined_relids,
|
|
801
|
+
)
|
|
802
|
+
cur.consume_stream(consumer)
|
|
803
|
+
except StopReplication: # completed batch or reached `upto_lsn`
|
|
804
|
+
pass
|
|
805
|
+
finally:
|
|
806
|
+
if cur is not None:
|
|
807
|
+
cur.connection.close()
|
|
808
|
+
self.last_commit_lsn = consumer.last_commit_lsn
|
|
809
|
+
self.generated_all = consumer.consumed_all
|
|
810
|
+
self.truncates = consumer.truncates
|
|
811
|
+
self.key_problems = consumer.key_problems
|
|
812
|
+
self.denied_seen = getattr(consumer, "denied_seen", set()) # Local patch (#4346)
|
|
813
|
+
self.unreviewed = getattr(consumer, "unreviewed", {})
|
|
814
|
+
self.quarantined = getattr(consumer, "quarantine_report", {})
|
|
815
|
+
return consumer
|
|
816
|
+
|
|
817
|
+
def __iter__(self) -> Iterator[Union[TDataItem, DataItemWithMeta]]:
|
|
818
|
+
"""Flat item stream for the single-schema `replication_resource` (upstream shape)."""
|
|
819
|
+
for items in self.collect().values():
|
|
820
|
+
yield from items
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
class MessageConsumer:
|
|
824
|
+
"""Consumes messages from a ReplicationCursor sequentially.
|
|
825
|
+
|
|
826
|
+
Generates data item for each `insert`, `update`, and `delete` message.
|
|
827
|
+
Processes in batches to limit memory usage.
|
|
828
|
+
Maintains message data needed by subsequent messages in internal state.
|
|
829
|
+
"""
|
|
830
|
+
|
|
831
|
+
def __init__(
|
|
832
|
+
self,
|
|
833
|
+
upto_lsn: int,
|
|
834
|
+
pub_ops: Dict[str, bool],
|
|
835
|
+
target_batch_size: int = 1000,
|
|
836
|
+
include_columns: Optional[Dict[str, Sequence[str]]] = None,
|
|
837
|
+
columns: Optional[Dict[str, TTableSchemaColumns]] = None,
|
|
838
|
+
credentials: Optional[ConnectionStringCredentials] = None,
|
|
839
|
+
keys_by_relation: Optional[Dict[Any, Sequence[str]]] = None,
|
|
840
|
+
policies_by_relation: Optional[Dict[Any, str]] = None,
|
|
841
|
+
baseline_lsn_by_relation: Optional[Dict[Any, int]] = None,
|
|
842
|
+
exclude_columns: Optional[Dict[Any, Sequence[str]]] = None,
|
|
843
|
+
exclude_columns_by_relid: Optional[Dict[int, Any]] = None,
|
|
844
|
+
approved_columns: Optional[Dict[Any, Any]] = None,
|
|
845
|
+
approved_by_relid: Optional[Dict[int, Any]] = None,
|
|
846
|
+
exclude_attnums_by_relid: Optional[Dict[int, Any]] = None,
|
|
847
|
+
reviewed_layout_by_relid: Optional[Dict[int, Any]] = None,
|
|
848
|
+
bound_relids: Optional[Any] = None,
|
|
849
|
+
quarantined_relids: Optional[set] = None,
|
|
850
|
+
) -> None:
|
|
851
|
+
self.keys_by_relation = keys_by_relation or {}
|
|
852
|
+
# Local patch (#4346): (schema, table) -> denied columns, dropped BY NAME from the
|
|
853
|
+
# hints and every data item. Exclusion, not an allow-list: a column added at the source
|
|
854
|
+
# mid-run still flows; `last_table_schema` keeps every column (positional zip).
|
|
855
|
+
self.exclude_columns = exclude_columns or {}
|
|
856
|
+
# relation OID (pgoutput relation_id) -> denied columns, kept by reference: once a
|
|
857
|
+
# relation was seen under a denied name its deny sticks to the OID, so a rename (the
|
|
858
|
+
# new name has no entry) cannot release the column — in this batch or a later one.
|
|
859
|
+
self.exclude_by_relid = (
|
|
860
|
+
exclude_columns_by_relid if exclude_columns_by_relid is not None else {})
|
|
861
|
+
# Local patch (#4346, Sonnet F1): reviewed columns per (schema, table), and the
|
|
862
|
+
# (schema, table) -> columns withheld because no review has seen them yet.
|
|
863
|
+
self.approved_columns = approved_columns or {}
|
|
864
|
+
self.approved_by_relid = approved_by_relid if approved_by_relid is not None else {}
|
|
865
|
+
self.exclude_attnums = (
|
|
866
|
+
exclude_attnums_by_relid if exclude_attnums_by_relid is not None else {})
|
|
867
|
+
self.unreviewed: Dict[Any, List[str]] = {}
|
|
868
|
+
self.reviewed_layout = (
|
|
869
|
+
reviewed_layout_by_relid if reviewed_layout_by_relid is not None else {})
|
|
870
|
+
self.bound_relids = None if bound_relids is None else frozenset(bound_relids)
|
|
871
|
+
# Relation ids whose changes are skipped — sticky: once a row of a relation was met
|
|
872
|
+
# under an unprovable description, nothing more of it loads until a resnapshot.
|
|
873
|
+
self.quarantined_relids: set = (
|
|
874
|
+
quarantined_relids if quarantined_relids is not None else set())
|
|
875
|
+
# relation id -> why its CURRENT description is unprovable. It becomes a quarantine
|
|
876
|
+
# only when a change past the relation's baseline arrives under it (a description
|
|
877
|
+
# older than a fresh baseline carries no rows to load, and must not re-flag it).
|
|
878
|
+
self._unproven: Dict[int, str] = {}
|
|
879
|
+
# relation id -> (schema, table, why), for the runner to report and persist. Keyed by
|
|
880
|
+
# the OID (Codex round 17): two relations met under one name are two quarantines.
|
|
881
|
+
self.quarantine_report: Dict[int, Tuple[str, str, str]] = {}
|
|
882
|
+
# (schema, table) -> compiled key policy (#3079 D-8). "append_only" merges on `lsn`.
|
|
883
|
+
self.policies_by_relation = policies_by_relation or {}
|
|
884
|
+
self.baseline_lsn_by_relation = baseline_lsn_by_relation or {}
|
|
885
|
+
# Commit LSN of the transaction being decoded (pgoutput Begin.final_lsn): the value the
|
|
886
|
+
# baseline boundary is compared against — never the per-record LSN, which is below the
|
|
887
|
+
# boundary for a transaction that was still in flight when the baseline was taken.
|
|
888
|
+
self._txn_commit_lsn: int = 0
|
|
889
|
+
self.truncates: List[Dict[str, Any]] = []
|
|
890
|
+
# Local patch (#4338): (schema, table) -> why a merge key could not be honoured for a
|
|
891
|
+
# record of this batch. The runner turns each into an error event — never silent.
|
|
892
|
+
self.key_problems: Dict[Any, str] = {}
|
|
893
|
+
self.upto_lsn = upto_lsn
|
|
894
|
+
self.pub_ops = pub_ops
|
|
895
|
+
self.target_batch_size = target_batch_size
|
|
896
|
+
self.include_columns = include_columns
|
|
897
|
+
self.columns = columns
|
|
898
|
+
self.credentials = credentials # true-PK resolution (#2851 patch)
|
|
899
|
+
|
|
900
|
+
self.consumed_all: bool = False
|
|
901
|
+
# data_items attribute maintains all data items
|
|
902
|
+
self.data_items: Dict[
|
|
903
|
+
int, List[Union[TDataItem, DataItemWithMeta]]
|
|
904
|
+
] = dict() # maps relation_id to list of data items
|
|
905
|
+
# other attributes only maintain last-seen values
|
|
906
|
+
self.last_table_schema: Dict[
|
|
907
|
+
int, TTableSchema
|
|
908
|
+
] = dict() # maps relation_id to table schema
|
|
909
|
+
# Local patch (#4345): (relation_id, table schema, items) per relation DESCRIPTION, in
|
|
910
|
+
# the order the descriptions arrived. `data_items[relation_id]` is the CURRENT group's
|
|
911
|
+
# list; a Relation message mid-batch (a rename, a column change — possibly mid-
|
|
912
|
+
# transaction) closes it here and opens a new one instead of stopping the stream.
|
|
913
|
+
self.groups: List[Any] = []
|
|
914
|
+
self.last_commit_ts: pendulum.DateTime
|
|
915
|
+
self.last_commit_lsn = None
|
|
916
|
+
|
|
917
|
+
def __call__(self, msg: ReplicationMessage) -> None:
|
|
918
|
+
"""Processes message received from stream."""
|
|
919
|
+
self.process_msg(msg)
|
|
920
|
+
|
|
921
|
+
def process_msg(self, msg: ReplicationMessage) -> None:
|
|
922
|
+
"""Processes encoded replication message.
|
|
923
|
+
|
|
924
|
+
Identifies message type and decodes accordingly.
|
|
925
|
+
Message treatment is different for various message types.
|
|
926
|
+
Breaks out of stream with StopReplication exception — only ever at a Commit — when
|
|
927
|
+
- `upto_lsn` is reached
|
|
928
|
+
- `target_batch_size` is reached
|
|
929
|
+
A table's schema change no longer stops the stream (#4345): it arrives mid-transaction
|
|
930
|
+
whenever rows were changed before the DDL, and a stop there left a partial
|
|
931
|
+
transaction and no commit LSN to restart from.
|
|
932
|
+
"""
|
|
933
|
+
op = msg.payload[0]
|
|
934
|
+
if op == 73: # ASCII for 'I'
|
|
935
|
+
self.process_change(Insert(msg.payload), msg.data_start)
|
|
936
|
+
elif op == 85: # ASCII for 'U'
|
|
937
|
+
self.process_change(Update(msg.payload), msg.data_start)
|
|
938
|
+
elif op == 68: # ASCII for 'D'
|
|
939
|
+
self.process_change(Delete(msg.payload), msg.data_start)
|
|
940
|
+
elif op == 66: # ASCII for 'B'
|
|
941
|
+
begin = Begin(msg.payload)
|
|
942
|
+
self.last_commit_ts = begin.commit_ts # type: ignore[assignment]
|
|
943
|
+
self._txn_commit_lsn = begin.lsn
|
|
944
|
+
elif op == 67: # ASCII for 'C'
|
|
945
|
+
self.process_commit(msg)
|
|
946
|
+
elif op == 82: # ASCII for 'R'
|
|
947
|
+
self.process_relation(Relation(msg.payload))
|
|
948
|
+
elif op == 84: # ASCII for 'T'
|
|
949
|
+
self.process_truncate(Truncate(msg.payload), msg.data_start)
|
|
950
|
+
else:
|
|
951
|
+
raise ValueError(f"Unknown replication op {op}")
|
|
952
|
+
|
|
953
|
+
def process_commit(self, msg: ReplicationMessage) -> None:
|
|
954
|
+
"""Updates object state when Commit message is observed.
|
|
955
|
+
|
|
956
|
+
Raises StopReplication when `upto_lsn` or `target_batch_size` is reached.
|
|
957
|
+
"""
|
|
958
|
+
self.last_commit_lsn = msg.data_start
|
|
959
|
+
if msg.data_start >= self.upto_lsn:
|
|
960
|
+
self.consumed_all = True
|
|
961
|
+
# Every retained group, not just each relation's current one (#4345 review): a
|
|
962
|
+
# relation whose description changed keeps its earlier rows in `groups`, and counting
|
|
963
|
+
# only `data_items` would let a batch outgrow `target_batch_size` without limit.
|
|
964
|
+
n_items = sum(len(items) for _rel_id, _schema, items in self.groups)
|
|
965
|
+
if self.consumed_all or n_items >= self.target_batch_size:
|
|
966
|
+
raise StopReplication
|
|
967
|
+
|
|
968
|
+
def process_relation(self, decoded_msg: Relation) -> None:
|
|
969
|
+
"""Processes a replication message of type Relation.
|
|
970
|
+
|
|
971
|
+
Stores table schema in object state.
|
|
972
|
+
Creates meta item to emit column hints while yielding data.
|
|
973
|
+
|
|
974
|
+
Local patch (#4345): a relation that already has items in this batch had its
|
|
975
|
+
description changed (renamed, columns altered). Upstream raised StopReplication here,
|
|
976
|
+
which is NOT a commit boundary — pgoutput sends this message mid-transaction when rows
|
|
977
|
+
were changed before the DDL — so the batch held a partial transaction and, with no
|
|
978
|
+
commit yet, no LSN to restart at: the worker wedged on `None >> 32`. Instead the
|
|
979
|
+
current group is closed with the rows it has (they keep the name and hints they were
|
|
980
|
+
written under) and decoding carries on to the commit under the new description.
|
|
981
|
+
"""
|
|
982
|
+
current = self.data_items.get(decoded_msg.relation_id)
|
|
983
|
+
if current is not None and len(current) < 2:
|
|
984
|
+
# Only the hints item, no rows: nothing was written under the old description.
|
|
985
|
+
self.groups = [g for g in self.groups if g[2] is not current]
|
|
986
|
+
# get table schema information from source and store in object state
|
|
987
|
+
table_name = decoded_msg.relation_name
|
|
988
|
+
columns: TTableSchemaColumns = {
|
|
989
|
+
c.name: _to_dlt_column_schema(c) for c in decoded_msg.columns
|
|
990
|
+
}
|
|
991
|
+
# Local patch (#2851 review follow-up): the Relation message's per-column key flag
|
|
992
|
+
# mirrors the REPLICA IDENTITY, not the primary key. With REPLICA IDENTITY FULL —
|
|
993
|
+
# which we set to make unchanged-TOAST updates decodable — EVERY column carries the
|
|
994
|
+
# flag, so the merge key silently became the whole row and every UPDATE landed as a
|
|
995
|
+
# duplicate instead of replacing. Resolve the true primary key from the catalog and
|
|
996
|
+
# override; keep the flags only if the lookup fails (never silently keep a
|
|
997
|
+
# whole-row key when a real PK exists).
|
|
998
|
+
# Declared key first (#3079): the compiled plan already resolved it, schema-qualified.
|
|
999
|
+
pk = self.keys_by_relation.get((decoded_msg.namespace, table_name))
|
|
1000
|
+
if pk is not None:
|
|
1001
|
+
pk = list(pk) if not isinstance(pk, str) else pk
|
|
1002
|
+
key_problem = "merge key unresolved: records merge on replica-identity flags"
|
|
1003
|
+
if pk is None and self.credentials is not None:
|
|
1004
|
+
try:
|
|
1005
|
+
conn = _get_conn(self.credentials)
|
|
1006
|
+
try:
|
|
1007
|
+
with conn.cursor() as cur:
|
|
1008
|
+
# The catalog is read NOW, the record was written THEN (#4345 review):
|
|
1009
|
+
# after a rename the name may belong to another table (a swap). Use its
|
|
1010
|
+
# key only if it is still this relation.
|
|
1011
|
+
cur.execute("SELECT to_regclass(%s)::oid", (
|
|
1012
|
+
_make_qualified_table_name(table_name, decoded_msg.namespace),))
|
|
1013
|
+
row = cur.fetchone()
|
|
1014
|
+
oid = row[0] if row else None
|
|
1015
|
+
if oid is not None and int(oid) == decoded_msg.relation_id:
|
|
1016
|
+
pk = _get_pk(cur, table_name, decoded_msg.namespace)
|
|
1017
|
+
elif oid is not None:
|
|
1018
|
+
key_problem = (
|
|
1019
|
+
"merge key unresolved: the name now belongs to another table at "
|
|
1020
|
+
"the source (renamed since the record was written); records "
|
|
1021
|
+
"merge on replica-identity flags")
|
|
1022
|
+
finally:
|
|
1023
|
+
conn.close()
|
|
1024
|
+
except Exception as exc: # noqa: BLE001
|
|
1025
|
+
logger.warning(
|
|
1026
|
+
f"could not resolve primary key of {decoded_msg.namespace}.{table_name}"
|
|
1027
|
+
f" ({exc}); falling back to replica-identity flags as the merge key"
|
|
1028
|
+
)
|
|
1029
|
+
if pk is not None:
|
|
1030
|
+
apply_true_primary_key(columns, pk)
|
|
1031
|
+
else:
|
|
1032
|
+
# Neither the compiled plan nor the source catalog knows this relation's key (a
|
|
1033
|
+
# relation renamed or dropped since the record was written, with no recorded key):
|
|
1034
|
+
# its records merge on replica-identity flags — report it (#4338).
|
|
1035
|
+
self.key_problems[(decoded_msg.namespace, table_name)] = key_problem
|
|
1036
|
+
|
|
1037
|
+
self.last_table_schema[decoded_msg.relation_id] = {
|
|
1038
|
+
"name": table_name,
|
|
1039
|
+
"namespace": decoded_msg.namespace, # #3079: relation identity is (schema, table)
|
|
1040
|
+
"columns": columns,
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
# apply user input
|
|
1044
|
+
# 1) exclude columns
|
|
1045
|
+
# Local patch (#4346): looked up by (namespace, table) — the bare name let a projection
|
|
1046
|
+
# on a.t reach b.t — and the deny-list is applied on top.
|
|
1047
|
+
self._withhold_unreviewed(decoded_msg, table_name)
|
|
1048
|
+
include_columns, exclude_columns = self._projection(
|
|
1049
|
+
decoded_msg.relation_id, decoded_msg.namespace, table_name)
|
|
1050
|
+
if include_columns is not None:
|
|
1051
|
+
columns = {k: v for k, v in columns.items() if k in include_columns}
|
|
1052
|
+
columns = {k: v for k, v in columns.items() if k not in exclude_columns}
|
|
1053
|
+
# 2) override source hints
|
|
1054
|
+
column_hints: TTableSchemaColumns = (
|
|
1055
|
+
dict() if self.columns is None else self.columns.get(table_name, dict())
|
|
1056
|
+
)
|
|
1057
|
+
for column_name, column_val in column_hints.items():
|
|
1058
|
+
columns[column_name] = merge_column(columns[column_name], column_val)
|
|
1059
|
+
|
|
1060
|
+
# add hints for replication columns
|
|
1061
|
+
append_only = (
|
|
1062
|
+
self.policies_by_relation.get((decoded_msg.namespace, table_name)) == "append_only"
|
|
1063
|
+
)
|
|
1064
|
+
columns["lsn"] = {"data_type": "bigint", "nullable": True}
|
|
1065
|
+
if self.pub_ops["update"] or self.pub_ops["delete"]:
|
|
1066
|
+
columns["lsn"]["dedup_sort"] = "desc"
|
|
1067
|
+
if self.pub_ops["delete"]:
|
|
1068
|
+
columns["deleted_ts"] = {
|
|
1069
|
+
# An append_only relation records arrivals; a DELETE record must not remove
|
|
1070
|
+
# the row version it refers to (#3079 D-8).
|
|
1071
|
+
"hard_delete": not append_only,
|
|
1072
|
+
"data_type": "timestamp",
|
|
1073
|
+
"nullable": True,
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
# determine write disposition
|
|
1077
|
+
write_disposition: TWriteDisposition = "append"
|
|
1078
|
+
if self.pub_ops["update"] or self.pub_ops["delete"]:
|
|
1079
|
+
write_disposition = "merge"
|
|
1080
|
+
if append_only:
|
|
1081
|
+
# A keyless relation gets primary_key=[], so a "merge" with no key silently
|
|
1082
|
+
# DEGRADES TO APPEND and a batch replayed after a crash lands twice. `lsn` is
|
|
1083
|
+
# unique per WAL record, so merging on it makes the replay exact; an UPDATE
|
|
1084
|
+
# still lands as a new row version, which is what append_only promises.
|
|
1085
|
+
columns["lsn"]["primary_key"] = True
|
|
1086
|
+
write_disposition = "merge"
|
|
1087
|
+
|
|
1088
|
+
# include meta item to emit hints while yielding data
|
|
1089
|
+
meta_item = dlt.mark.with_hints(
|
|
1090
|
+
[],
|
|
1091
|
+
dlt.mark.make_hints(
|
|
1092
|
+
table_name=table_name,
|
|
1093
|
+
write_disposition=write_disposition,
|
|
1094
|
+
columns=columns,
|
|
1095
|
+
),
|
|
1096
|
+
create_table_variant=True,
|
|
1097
|
+
)
|
|
1098
|
+
items: List[Union[TDataItem, DataItemWithMeta]] = [meta_item]
|
|
1099
|
+
self.data_items[decoded_msg.relation_id] = items
|
|
1100
|
+
self.groups.append(
|
|
1101
|
+
(decoded_msg.relation_id, self.last_table_schema[decoded_msg.relation_id], items))
|
|
1102
|
+
|
|
1103
|
+
def _current_shape(self, relation_id: int):
|
|
1104
|
+
"""Local patch (#4346): `published_shape` of this relation in the CURRENT catalogue, or
|
|
1105
|
+
None when it cannot be read (then nothing can be proven)."""
|
|
1106
|
+
if self.credentials is None:
|
|
1107
|
+
return None
|
|
1108
|
+
try:
|
|
1109
|
+
conn = _get_conn(self.credentials)
|
|
1110
|
+
try:
|
|
1111
|
+
with conn.cursor() as cur:
|
|
1112
|
+
return published_shape(cur, relation_id)
|
|
1113
|
+
finally:
|
|
1114
|
+
conn.close()
|
|
1115
|
+
except Exception: # noqa: BLE001 — unknown identity is handled as "quarantine"
|
|
1116
|
+
return None
|
|
1117
|
+
|
|
1118
|
+
def _prove_layout(self, relid: int, key, decoded_msg: Relation, exclude, denied_attnums):
|
|
1119
|
+
"""Local patch (#4346, Codex round 17): (why, layout). `why` says why this description
|
|
1120
|
+
cannot be trusted; else `layout` is the relation's reviewed layout, which the
|
|
1121
|
+
description matches EXACTLY, so each position is the reviewed attnum at that position.
|
|
1122
|
+
|
|
1123
|
+
The rule, with no inference: the description's columns — count, names in order, type
|
|
1124
|
+
OIDs, typmods and replica-identity key flags — must equal the layout the relation's
|
|
1125
|
+
baseline recorded under its locks, and the catalogue must still hold every reviewed
|
|
1126
|
+
attnum and publish no column the review saw hidden. Anything else, of any kind (an
|
|
1127
|
+
added column included), is unproven and quarantines the relation until a resnapshot.
|
|
1128
|
+
|
|
1129
|
+
Why the catalogue half is needed. The message carries no attnums. A dropped attnum is
|
|
1130
|
+
never reused and a new column always gets a higher one, so while every reviewed attnum
|
|
1131
|
+
is live and nothing hidden surfaced, a message of exactly the reviewed length IS the
|
|
1132
|
+
reviewed attnums in order. Drop `email`, rename `password_hash` to `email`, add a new
|
|
1133
|
+
`password_hash`: the message reads exactly like the review, and only the dropped
|
|
1134
|
+
attnum gives it away. A generated column whose expression is dropped joins the
|
|
1135
|
+
published set below a reviewed attnum (Codex round 17) — the count catches it, the
|
|
1136
|
+
hidden set too."""
|
|
1137
|
+
if self.bound_relids is not None and relid not in self.bound_relids:
|
|
1138
|
+
# A table renamed onto a denied name mid-run: which of its columns is the denied
|
|
1139
|
+
# one was never bound at a trusted moment (Codex round 16, P1).
|
|
1140
|
+
return ("met under a denied name mid-run; its deny was not bound at run start or "
|
|
1141
|
+
"by a baseline, so the denied column's identity is unknown", None)
|
|
1142
|
+
layout = self.reviewed_layout.get(relid)
|
|
1143
|
+
if layout is None:
|
|
1144
|
+
return ("no reviewed layout: the table was not baselined with its deny, so its "
|
|
1145
|
+
"column identities are unknown until a resnapshot", None)
|
|
1146
|
+
described = [(c.name, int(c.type_id), int(c.atttypmod), 1 if c.part_of_pkey else 0)
|
|
1147
|
+
for c in decoded_msg.columns]
|
|
1148
|
+
if described != layout.described():
|
|
1149
|
+
return ("the column description differs from the reviewed layout (a column added, "
|
|
1150
|
+
"dropped, renamed, reordered or retyped, or the replica identity changed): "
|
|
1151
|
+
"any DDL on a table with denied columns needs a resnapshot", None)
|
|
1152
|
+
shape = self._current_shape(relid)
|
|
1153
|
+
if shape is None:
|
|
1154
|
+
return "column identity unreadable (catalogue lookup failed)", None
|
|
1155
|
+
current, _ever_dropped = shape
|
|
1156
|
+
live = {c[0] for c in current.columns} | set(current.hidden)
|
|
1157
|
+
if any(c[0] not in live for c in layout.columns):
|
|
1158
|
+
return ("a reviewed column was dropped since the review: identity unprovable",
|
|
1159
|
+
None)
|
|
1160
|
+
if layout.hidden & {c[0] for c in current.columns}:
|
|
1161
|
+
return ("a column that was hidden at the review (generated) is published now: "
|
|
1162
|
+
"identity unprovable", None)
|
|
1163
|
+
attnum_of = {c[1]: c[0] for c in layout.columns}
|
|
1164
|
+
denied = set(denied_attnums) | {attnum_of[c] for c in exclude if c in attnum_of}
|
|
1165
|
+
for k in self.keys_by_relation.get(key) or ():
|
|
1166
|
+
if k in attnum_of and attnum_of[k] in denied:
|
|
1167
|
+
return f"merge-key column {k!r} is a denied column", None
|
|
1168
|
+
return None, layout
|
|
1169
|
+
|
|
1170
|
+
def _withhold_unreviewed(self, decoded_msg: Relation, table_name: str) -> None:
|
|
1171
|
+
"""Local patch (#4346): judge a description of a relation that carries a deny. Proven
|
|
1172
|
+
(`_prove_layout`): withhold each column whose attnum is denied and each reviewed column
|
|
1173
|
+
the stored signature does not approve. Unproven: mark it; a change under it quarantines
|
|
1174
|
+
the relation (`process_change`) — withholding columns instead would merge NULLs over
|
|
1175
|
+
good bronze values. Merge-key columns are never withheld (a key cannot be denied)."""
|
|
1176
|
+
relid = decoded_msg.relation_id
|
|
1177
|
+
key = (decoded_msg.namespace or "", table_name)
|
|
1178
|
+
self._unproven.pop(relid, None) # a new description is judged afresh
|
|
1179
|
+
_include, exclude = self._projection(relid, decoded_msg.namespace, table_name)
|
|
1180
|
+
denied_attnums = set(self.exclude_attnums.get(relid, ()))
|
|
1181
|
+
if not exclude and not denied_attnums:
|
|
1182
|
+
return
|
|
1183
|
+
why, layout = self._prove_layout(relid, key, decoded_msg, exclude, denied_attnums)
|
|
1184
|
+
if why:
|
|
1185
|
+
self._unproven[relid] = why
|
|
1186
|
+
return
|
|
1187
|
+
attnum_of = {c[1]: c[0] for c in layout.columns}
|
|
1188
|
+
# Bound by POSITION, which is proven — the identity the runner persists (#4346).
|
|
1189
|
+
learned = {c: attnum_of[c] for c in exclude if c in attnum_of}
|
|
1190
|
+
denied_attnums |= set(learned.values())
|
|
1191
|
+
self.exclude_attnums[relid] = frozenset(denied_attnums)
|
|
1192
|
+
if not hasattr(self, "denied_seen"):
|
|
1193
|
+
self.denied_seen = set()
|
|
1194
|
+
self.denied_seen.update((relid, key[0], key[1], c, a) for c, a in learned.items())
|
|
1195
|
+
approved = self.approved_by_relid.get(relid)
|
|
1196
|
+
if approved is None:
|
|
1197
|
+
approved = self.approved_columns.get(key)
|
|
1198
|
+
if approved is not None:
|
|
1199
|
+
self.approved_by_relid[relid] = approved
|
|
1200
|
+
keys = set(self.keys_by_relation.get(key) or ())
|
|
1201
|
+
withheld, unreviewed = set(), set()
|
|
1202
|
+
for c in decoded_msg.columns:
|
|
1203
|
+
n = c.name
|
|
1204
|
+
if n in exclude or n in keys:
|
|
1205
|
+
continue
|
|
1206
|
+
if attnum_of[n] in denied_attnums:
|
|
1207
|
+
withheld.add(n) # the denied column, by its attnum
|
|
1208
|
+
elif approved is None or n not in approved:
|
|
1209
|
+
withheld.add(n)
|
|
1210
|
+
unreviewed.add(n) # not in the reviewed signature
|
|
1211
|
+
if withheld:
|
|
1212
|
+
self.exclude_by_relid[relid] = exclude | frozenset(withheld)
|
|
1213
|
+
if unreviewed:
|
|
1214
|
+
self.unreviewed[key] = sorted(set(self.unreviewed.get(key, [])) | unreviewed)
|
|
1215
|
+
|
|
1216
|
+
def _projection(self, relation_id: int, namespace: str, table_name: str):
|
|
1217
|
+
"""Local patch (#4346): (include, exclude) for one relation description. Include is
|
|
1218
|
+
keyed by (schema, table); exclude is the name's deny UNION the OID's, and the union is
|
|
1219
|
+
written back to the OID so it survives a rename."""
|
|
1220
|
+
key = (namespace or "", table_name)
|
|
1221
|
+
include = None
|
|
1222
|
+
include_map = getattr(self, "include_columns", None)
|
|
1223
|
+
if include_map is not None:
|
|
1224
|
+
# (schema, table) first; the bare name is upstream's documented contract for the
|
|
1225
|
+
# single-schema `replication_resource` wrapper, kept so its callers do not widen.
|
|
1226
|
+
include = include_map.get(key, include_map.get(table_name))
|
|
1227
|
+
by_relid = getattr(self, "exclude_by_relid", None)
|
|
1228
|
+
if by_relid is None:
|
|
1229
|
+
by_relid = self.exclude_by_relid = {}
|
|
1230
|
+
by_name = frozenset(getattr(self, "exclude_columns", {}).get(key, ()))
|
|
1231
|
+
exclude = by_name | frozenset(by_relid.get(relation_id, ()))
|
|
1232
|
+
if exclude:
|
|
1233
|
+
by_relid[relation_id] = exclude
|
|
1234
|
+
# (The (oid, schema, table, column, attnum) the runner persists is recorded only by
|
|
1235
|
+
# `_withhold_unreviewed`, from a PROVEN position — never by a name lookup that a rename
|
|
1236
|
+
# can race, Codex round 16.)
|
|
1237
|
+
return include, exclude
|
|
1238
|
+
|
|
1239
|
+
def _relation_key(self, relation_id: int):
|
|
1240
|
+
schema = self.last_table_schema[relation_id]
|
|
1241
|
+
return schema.get("namespace") or "", schema["name"]
|
|
1242
|
+
|
|
1243
|
+
def _in_baseline(self, relation_id: int) -> bool:
|
|
1244
|
+
"""True when the current transaction is already in this relation's baseline: its commit
|
|
1245
|
+
LSN is below the consistent point the baseline was taken at (strict `<`, the decoder's
|
|
1246
|
+
own skip predicate). Loading it again would duplicate an append_only row."""
|
|
1247
|
+
boundary = self.baseline_lsn_by_relation.get(self._relation_key(relation_id))
|
|
1248
|
+
return boundary is not None and self._txn_commit_lsn < boundary
|
|
1249
|
+
|
|
1250
|
+
def process_truncate(self, decoded_msg: Truncate, msg_start_lsn: int) -> None:
|
|
1251
|
+
"""Local patch (#3079 review): a source TRUNCATE is data, not a warning. Per relation:
|
|
1252
|
+
a keyed mirror must empty — the rows collected so far in this batch are discarded (the
|
|
1253
|
+
truncate supersedes them) and the runner deletes the destination rows BEFORE loading
|
|
1254
|
+
what follows, so batch order holds; an append_only history keeps every arrival and only
|
|
1255
|
+
records the marker. Either way the runner gets a `truncates` entry to act on."""
|
|
1256
|
+
for relation_id in decoded_msg.relation_ids:
|
|
1257
|
+
if relation_id not in self.last_table_schema:
|
|
1258
|
+
continue # not a published relation of ours (never seen a Relation message)
|
|
1259
|
+
if self._in_baseline(relation_id):
|
|
1260
|
+
continue
|
|
1261
|
+
if relation_id in getattr(self, "quarantined_relids", ()):
|
|
1262
|
+
continue # Local patch (#4346): nothing of a quarantined relation applies
|
|
1263
|
+
namespace, table_name = self._relation_key(relation_id)
|
|
1264
|
+
keyed = self.policies_by_relation.get((namespace, table_name)) != "append_only"
|
|
1265
|
+
if keyed:
|
|
1266
|
+
# Every earlier group bound for the truncated DESTINATION name — whichever
|
|
1267
|
+
# relation wrote it (#4345): a column change splits a table's rows over two
|
|
1268
|
+
# groups, and a rename swap (or drop and re-create) puts another relation's rows
|
|
1269
|
+
# under this name; any left would load AFTER the runner's delete and resurrect
|
|
1270
|
+
# truncated rows. A group under another name targets another table: kept.
|
|
1271
|
+
for _rel_id, schema, items in self.groups:
|
|
1272
|
+
if (schema.get("namespace") or "", schema["name"]) == (namespace, table_name):
|
|
1273
|
+
del items[1:] # keep the meta item
|
|
1274
|
+
self.truncates.append({
|
|
1275
|
+
"namespace": namespace, "table": table_name, "lsn": msg_start_lsn,
|
|
1276
|
+
"commit_lsn": self._txn_commit_lsn, "commit_ts": self.last_commit_ts,
|
|
1277
|
+
"applied": "delete_all" if keyed else "recorded_only",
|
|
1278
|
+
})
|
|
1279
|
+
|
|
1280
|
+
def process_change(
|
|
1281
|
+
self, decoded_msg: Union[Insert, Update, Delete], msg_start_lsn: int
|
|
1282
|
+
) -> None:
|
|
1283
|
+
"""Processes replication message of type Insert, Update, or Delete.
|
|
1284
|
+
|
|
1285
|
+
Adds data item for inserted/updated/deleted record to instance attribute.
|
|
1286
|
+
"""
|
|
1287
|
+
if self._in_baseline(decoded_msg.relation_id):
|
|
1288
|
+
return
|
|
1289
|
+
# Local patch (#4346): a change under an unproven description quarantines the relation,
|
|
1290
|
+
# and a quarantine is sticky — nothing more of it loads (this batch, later batches of
|
|
1291
|
+
# the drain, later runs via the persisted mark) until a resnapshot re-reviews it.
|
|
1292
|
+
relid = decoded_msg.relation_id
|
|
1293
|
+
why = getattr(self, "_unproven", {}).get(relid)
|
|
1294
|
+
if why is not None and relid not in self.quarantined_relids:
|
|
1295
|
+
self.quarantined_relids.add(relid)
|
|
1296
|
+
self.quarantine_report[relid] = (*self._relation_key(relid), why)
|
|
1297
|
+
if relid in getattr(self, "quarantined_relids", ()):
|
|
1298
|
+
return
|
|
1299
|
+
table_name = self.last_table_schema[decoded_msg.relation_id]["name"]
|
|
1300
|
+
# Local patch (#4346): schema-qualified projection, deny-list included.
|
|
1301
|
+
include_columns, exclude_columns = self._projection(
|
|
1302
|
+
decoded_msg.relation_id, *self._relation_key(decoded_msg.relation_id))
|
|
1303
|
+
if isinstance(decoded_msg, (Insert, Update)):
|
|
1304
|
+
column_data = decoded_msg.new_tuple.column_data
|
|
1305
|
+
if isinstance(decoded_msg, Update):
|
|
1306
|
+
column_data = resolve_unchanged_toast(
|
|
1307
|
+
column_data,
|
|
1308
|
+
decoded_msg.old_tuple.column_data
|
|
1309
|
+
if decoded_msg.optional_tuple_identifier == "O"
|
|
1310
|
+
else None,
|
|
1311
|
+
table_name,
|
|
1312
|
+
)
|
|
1313
|
+
elif isinstance(decoded_msg, Delete):
|
|
1314
|
+
column_data = decoded_msg.old_tuple.column_data
|
|
1315
|
+
data_item = self.gen_data_item(
|
|
1316
|
+
data=column_data,
|
|
1317
|
+
column_schema=self.last_table_schema[decoded_msg.relation_id]["columns"],
|
|
1318
|
+
lsn=msg_start_lsn,
|
|
1319
|
+
commit_ts=self.last_commit_ts,
|
|
1320
|
+
for_delete=isinstance(decoded_msg, Delete),
|
|
1321
|
+
include_columns=include_columns,
|
|
1322
|
+
exclude_columns=exclude_columns,
|
|
1323
|
+
)
|
|
1324
|
+
if isinstance(decoded_msg, Update):
|
|
1325
|
+
old_key_delete = self._old_key_delete(decoded_msg, data_item, msg_start_lsn)
|
|
1326
|
+
if old_key_delete is not None:
|
|
1327
|
+
self.data_items[decoded_msg.relation_id].append(old_key_delete)
|
|
1328
|
+
self.data_items[decoded_msg.relation_id].append(data_item)
|
|
1329
|
+
|
|
1330
|
+
@staticmethod
|
|
1331
|
+
def gen_data_item(
|
|
1332
|
+
data: List[ColumnData],
|
|
1333
|
+
column_schema: TTableSchemaColumns,
|
|
1334
|
+
lsn: int,
|
|
1335
|
+
commit_ts: pendulum.DateTime,
|
|
1336
|
+
for_delete: bool,
|
|
1337
|
+
include_columns: Optional[Sequence[str]] = None,
|
|
1338
|
+
exclude_columns: Sequence[str] = (), # Local patch (#4346)
|
|
1339
|
+
) -> TDataItem:
|
|
1340
|
+
"""Generates data item from replication message data and corresponding metadata."""
|
|
1341
|
+
data_item = {
|
|
1342
|
+
schema["name"]: _to_dlt_val(
|
|
1343
|
+
val=data.col_data,
|
|
1344
|
+
data_type=schema["data_type"],
|
|
1345
|
+
byte1=data.col_data_category,
|
|
1346
|
+
for_delete=for_delete,
|
|
1347
|
+
)
|
|
1348
|
+
for (schema, data) in zip(column_schema.values(), data)
|
|
1349
|
+
if (True if include_columns is None else schema["name"] in include_columns)
|
|
1350
|
+
# Local patch (#4346): filtered by NAME after the positional zip pairs each column
|
|
1351
|
+
# with its value — dropping a column before the zip would shift every later value.
|
|
1352
|
+
and schema["name"] not in exclude_columns
|
|
1353
|
+
}
|
|
1354
|
+
data_item["lsn"] = lsn
|
|
1355
|
+
if for_delete:
|
|
1356
|
+
data_item["deleted_ts"] = commit_ts
|
|
1357
|
+
return data_item
|
|
1358
|
+
|
|
1359
|
+
def _old_key_delete(self, decoded_msg: Update, new_item: TDataItem,
|
|
1360
|
+
msg_start_lsn: int) -> Optional[TDataItem]:
|
|
1361
|
+
"""Local patch (#4338): an UPDATE that changes the primary key. The merge keys on the
|
|
1362
|
+
NEW tuple, so without this the old key's row stays in the mirror for ever (a re-keyed
|
|
1363
|
+
id leaves a ghost). With REPLICA IDENTITY FULL or a key identity the old tuple carries
|
|
1364
|
+
the old key: emit a key-only delete for it at the same LSN — the keys differ, so lsn dedup keeps
|
|
1365
|
+
both, and a later re-use of the old key in the batch has a higher LSN and wins."""
|
|
1366
|
+
relation_id = decoded_msg.relation_id
|
|
1367
|
+
table = self.last_table_schema[relation_id]
|
|
1368
|
+
namespace, table_name = table.get("namespace") or "", table["name"]
|
|
1369
|
+
if self.policies_by_relation.get((namespace, table_name)) == "append_only":
|
|
1370
|
+
return None
|
|
1371
|
+
if decoded_msg.optional_tuple_identifier not in ("O", "K") or decoded_msg.old_tuple is None:
|
|
1372
|
+
return None
|
|
1373
|
+
columns = table["columns"]
|
|
1374
|
+
pk = [name for name, col in columns.items() if col.get("primary_key")]
|
|
1375
|
+
if not pk:
|
|
1376
|
+
return None
|
|
1377
|
+
# Local patch (#4346): a key that falls back to REPLICA IDENTITY FULL's flags names every
|
|
1378
|
+
# column, denied ones included — a tombstone keyed on them would carry the denied value.
|
|
1379
|
+
_include, exclude = self._projection(relation_id, namespace, table_name)
|
|
1380
|
+
if exclude & set(pk):
|
|
1381
|
+
self.key_problems[(namespace, table_name)] = (
|
|
1382
|
+
"a key-changing UPDATE's merge key includes a denied column; no tombstone was "
|
|
1383
|
+
"emitted — resnapshot this table")
|
|
1384
|
+
return None
|
|
1385
|
+
# A 'K' tuple carries only the REPLICA IDENTITY columns, which need not be the merge
|
|
1386
|
+
# key (an identity on a secondary unique index): a key field missing from the raw tuple
|
|
1387
|
+
# ('n' null, 'u' unchanged-TOAST, or absent) must never become a dummy value in a
|
|
1388
|
+
# tombstone — that could delete an unrelated row. Skip it and report instead.
|
|
1389
|
+
raw = decoded_msg.old_tuple.column_data
|
|
1390
|
+
position = {name: i for i, name in enumerate(columns)}
|
|
1391
|
+
if any(position[k] >= len(raw) or raw[position[k]].col_data_category in ("n", "u")
|
|
1392
|
+
for k in pk):
|
|
1393
|
+
self.key_problems[(namespace, table_name)] = (
|
|
1394
|
+
"a key-changing UPDATE's old tuple lacks the merge key; the old key's row "
|
|
1395
|
+
"may remain in bronze — resnapshot this table")
|
|
1396
|
+
return None
|
|
1397
|
+
old_item = self.gen_data_item(
|
|
1398
|
+
data=decoded_msg.old_tuple.column_data, column_schema=columns,
|
|
1399
|
+
lsn=msg_start_lsn, commit_ts=self.last_commit_ts, for_delete=True)
|
|
1400
|
+
if all(old_item.get(k) == new_item.get(k) for k in pk):
|
|
1401
|
+
return None
|
|
1402
|
+
# The tombstone carries the key and the delete metadata only — never a column the
|
|
1403
|
+
# projection (`include_columns`) leaves out, and nothing the delete does not need.
|
|
1404
|
+
return {**{k: old_item.get(k) for k in pk},
|
|
1405
|
+
"lsn": old_item["lsn"], "deleted_ts": old_item["deleted_ts"]}
|