@@ -513,6 +513,7 @@ func PullCdcRecords[Items model.Items](
513513 )
514514
515515 records := req .RecordStream
516+ warnedReplIdentTables := make (map [string ]struct {})
516517 var totalRecords int64
517518 var fetchedBytes , totalFetchedBytes , allFetchedBytes atomic.Int64
518519 // clientXLogPos is the last checkpoint id, we need to ack that we have processed
@@ -764,7 +765,7 @@ func PullCdcRecords[Items model.Items](
764765
765766 logger .Debug ("XLogData" ,
766767 slog .Any ("WALStart" , xld .WALStart ), slog .Any ("ServerWALEnd" , xld .ServerWALEnd ), slog .Time ("ServerTime" , xld .ServerTime ))
767- rec , err := processMessage (ctx , p , records , xld , clientXLogPos , processor )
768+ rec , err := processMessage (ctx , p , records , xld , clientXLogPos , processor , warnedReplIdentTables )
768769 if err != nil {
769770 return exceptions .NewPostgresLogicalMessageProcessingError (err )
770771 }
@@ -937,6 +938,7 @@ func processMessage[Items model.Items](
937938 xld pglogrepl.XLogData ,
938939 currentClientXlogPos pglogrepl.LSN ,
939940 processor replProcessor [Items ],
941+ warnedReplIdentTables map [string ]struct {},
940942) (model.Record [Items ], error ) {
941943 logger := internal .LoggerFromCtx (ctx )
942944 logicalMsg , err := pglogrepl .Parse (xld .WALData )
@@ -959,7 +961,7 @@ func processMessage[Items model.Items](
959961 case * pglogrepl.InsertMessage :
960962 return processInsertMessage (p , xld .WALStart , msg , processor , customTypeMapping )
961963 case * pglogrepl.UpdateMessage :
962- return processUpdateMessage (p , xld .WALStart , msg , processor , customTypeMapping )
964+ return processUpdateMessage (p , xld .WALStart , msg , processor , customTypeMapping , warnedReplIdentTables )
963965 case * pglogrepl.DeleteMessage :
964966 return processDeleteMessage (p , xld .WALStart , msg , processor , customTypeMapping )
965967 case * pglogrepl.CommitMessage :
@@ -1071,6 +1073,7 @@ func processUpdateMessage[Items model.Items](
10711073 msg * pglogrepl.UpdateMessage ,
10721074 processor replProcessor [Items ],
10731075 customTypeMapping map [uint32 ]pkg_pg.CustomDataType ,
1076+ warnedReplIdentTables map [string ]struct {},
10741077) (model.Record [Items ], error ) {
10751078 relID := p .getParentRelIDIfPartitioned (msg .RelationID )
10761079
@@ -1082,6 +1085,20 @@ func processUpdateMessage[Items model.Items](
10821085 // log lsn and relation id for debugging
10831086 p .logger .Debug ("UpdateMessage" , slog .Any ("LSN" , lsn ), slog .Uint64 ("RelationID" , uint64 (relID )), slog .String ("Relation Name" , tableName ))
10841087
1088+ // By default, replica identity is the same as source PK/destination ordering key,
1089+ // so when it changes the destination can see duplicates or missed deletes.
1090+ // UpdateMessageTupleTypeKey fires when any replica identity column changes; if a replica identity is a superset
1091+ // of the dest ordering key (much rarer), the change to the extra columns is harmless and the warning doesn't apply.
1092+ // Replica identity full (OldTupleType=UpdateMessageTupleTypeOld) would require us to compare column values
1093+ // ourselves which we're avoiding for perf reasons, so absence of warning doesn't guarantee there wasn't a change.
1094+ if msg .OldTupleType == pglogrepl .UpdateMessageTupleTypeKey {
1095+ if _ , warned := warnedReplIdentTables [tableName ]; ! warned {
1096+ warnedReplIdentTables [tableName ] = struct {}{}
1097+ p .logger .Warn ("UpdateMessage changed a replica identity column, destination may see duplicates/missed deletes" ,
1098+ slog .String ("tableName" , tableName ))
1099+ }
1100+ }
1101+
10851102 rel , ok := p .relationMessageMapping [relID ]
10861103 if ! ok {
10871104 return nil , fmt .Errorf ("unknown relation id %d for table %s" , relID , tableName )
0 commit comments