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.
@@ -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"]}