Skip to content

Commit 340d734

Browse files
feat(pg-pg): support ingestion to target tables having FK relationships (#4219)
> [!NOTE] This PR is contained to the PG type system only. > [!IMPORTANT] PeerDB's INSERTs will no longer trigger regular BEFORE/AFTER triggers on top of destination tables after this change for PG type system mirrors. If you were relying on this behaviour, reach out to @Amogh-Bharadwaj on [Slack](https://slack.peerdb.io/) ### Context For Postgres to Postgres apples-to-apples migrations, users would often pre-create their destination tables matching their source table schemas, including indexes, primary keys and foreign keys. ### Problem statement Currently, PeerDB's ingestion to target tables can be blocked by foreign key violations depending on the order of the tables it iterates though. Ideally, PeerDB's ingestion should be allowed to these tables for the purpose of migration. ### This PR This PR sets [`session_replication_role`](https://www.postgresql.org/docs/17/runtime-config-client.html#GUC-SESSION-REPLICATION-ROLE) to `replica` within the scope of the session in which inserts are run. - **Initial load**: Sets `SET LOCAL session_replication_role = 'replica'` on the dedicated per-partition connection in `syncQRepRecords` before the transaction begins (PG type system only). - **CDC**: Sets `SET LOCAL session_replication_role = 'replica'` inside the normalize transaction in `normalizeBatch` (PG type system only). ### Testing - An end to end test has been added, and verified that without this PR it fails.
1 parent 85f2c07 commit 340d734

4 files changed

Lines changed: 121 additions & 0 deletions

File tree

flow/connectors/postgres/client.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const (
4848
sync_batch_id=EXCLUDED.sync_batch_id`
4949
checkIfJobMetadataExistsSQL = "SELECT EXISTS(SELECT * FROM %s.%s WHERE mirror_job_name=$1)"
5050
updateMetadataForNormalizeRecordsSQL = "UPDATE %s.%s SET normalize_batch_id=$1 WHERE mirror_job_name=$2"
51+
setSessionReplicaRoleSQL = "SET LOCAL session_replication_role = 'replica'"
5152

5253
getDistinctDestinationTableNamesSQL = `SELECT DISTINCT _peerdb_destination_table_name FROM %s.%s WHERE
5354
_peerdb_batch_id=$1`

flow/connectors/postgres/postgres.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -786,6 +786,16 @@ func (c *PostgresConnector) normalizeBatch(
786786
}
787787
defer shared.RollbackTx(tx, c.logger)
788788

789+
for _, tableName := range destinationTableNames {
790+
if schema, ok := normalizeStmtGen.tableSchemaMapping[tableName]; ok && schema.System == protos.TypeSystem_PG {
791+
if _, err := tx.Exec(ctx, setSessionReplicaRoleSQL); err != nil {
792+
return 0, fmt.Errorf("failed to set session_replication_role to replica: %w", err)
793+
}
794+
c.logger.Info("set session_replication_role to replica for PG type system normalize")
795+
break
796+
}
797+
}
798+
789799
batch := &pgx.Batch{}
790800
var entries []batchEntry
791801
for _, destinationTableName := range destinationTableNames {

flow/connectors/postgres/qrep.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,13 @@ func syncQRepRecords(
620620
}
621621
defer shared.RollbackTx(tx, c.logger)
622622

623+
if config.System == protos.TypeSystem_PG {
624+
if _, err := tx.Exec(ctx, setSessionReplicaRoleSQL); err != nil {
625+
return 0, nil, fmt.Errorf("failed to set session_replication_role to replica: %w", err)
626+
}
627+
c.logger.Info("set session_replication_role to replica on destination for PG type system initial load")
628+
}
629+
623630
// Step 2: Insert records into destination table
624631
var numRowsSynced int64
625632

flow/e2e/postgres_test.go

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,3 +1419,106 @@ func (s PeerFlowE2ETestSuitePG) Test_Table_With_Excluded_PK_And_ReplicaIdentityF
14191419
env.Cancel(s.t.Context())
14201420
RequireEnvCanceled(s.t, env)
14211421
}
1422+
1423+
func (s PeerFlowE2ETestSuitePG) Test_PG_PG_Target_Foreign_Keys() {
1424+
tc := NewTemporalClient(s.t)
1425+
1426+
srcTableName1 := s.attachSchemaSuffix("test_pg_pg_fk_src_1")
1427+
dstTableName1 := s.attachSchemaSuffix("test_pg_pg_fk_dst_1")
1428+
srcTableName2 := s.attachSchemaSuffix("test_pg_pg_fk_src_2")
1429+
dstTableName2 := s.attachSchemaSuffix("test_pg_pg_fk_dst_2")
1430+
1431+
// Source parent table
1432+
_, err := s.Conn().Exec(s.t.Context(), fmt.Sprintf(`
1433+
CREATE TABLE IF NOT EXISTS %s (
1434+
id SERIAL PRIMARY KEY,
1435+
val TEXT NOT NULL
1436+
)`, srcTableName1))
1437+
require.NoError(s.t, err)
1438+
1439+
// Source child table with FK referencing parent
1440+
_, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf(`
1441+
CREATE TABLE IF NOT EXISTS %s (
1442+
id SERIAL PRIMARY KEY,
1443+
parent_id BIGINT NOT NULL REFERENCES %s(id),
1444+
val TEXT NOT NULL
1445+
)`, srcTableName2, srcTableName1))
1446+
require.NoError(s.t, err)
1447+
1448+
// Destination parent table
1449+
_, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf(`
1450+
CREATE TABLE IF NOT EXISTS %s (
1451+
id SERIAL PRIMARY KEY,
1452+
val TEXT NOT NULL
1453+
)`, dstTableName1))
1454+
require.NoError(s.t, err)
1455+
1456+
// Destination child table with FK referencing destination parent
1457+
_, err = s.Conn().Exec(s.t.Context(), fmt.Sprintf(`
1458+
CREATE TABLE IF NOT EXISTS %s (
1459+
id SERIAL PRIMARY KEY,
1460+
parent_id BIGINT NOT NULL REFERENCES %s(id),
1461+
val TEXT NOT NULL
1462+
)`, dstTableName2, dstTableName1))
1463+
require.NoError(s.t, err)
1464+
1465+
// Insert rows only into the child table, bypassing FK checks via session_replication_role
1466+
rowsTx, err := s.Conn().Begin(s.t.Context())
1467+
require.NoError(s.t, err)
1468+
_, err = rowsTx.Exec(s.t.Context(), "SET LOCAL session_replication_role = 'replica'")
1469+
require.NoError(s.t, err)
1470+
for i := range 5 {
1471+
_, err = rowsTx.Exec(s.t.Context(), fmt.Sprintf(
1472+
`INSERT INTO %s(parent_id, val) VALUES ($1, $2)`, srcTableName2), int64(i+100), fmt.Sprintf("child_val_%d", i))
1473+
require.NoError(s.t, err)
1474+
}
1475+
require.NoError(s.t, rowsTx.Commit(s.t.Context()))
1476+
1477+
config := &protos.FlowConnectionConfigs{
1478+
FlowJobName: s.attachSuffix("test_pg_pg_fk"),
1479+
DestinationName: s.Peer().Name,
1480+
TableMappings: []*protos.TableMapping{
1481+
{
1482+
SourceTableIdentifier: srcTableName1,
1483+
DestinationTableIdentifier: dstTableName1,
1484+
},
1485+
{
1486+
SourceTableIdentifier: srcTableName2,
1487+
DestinationTableIdentifier: dstTableName2,
1488+
},
1489+
},
1490+
SourceName: GeneratePostgresPeer(s.t).Name,
1491+
MaxBatchSize: 100,
1492+
DoInitialSnapshot: true,
1493+
System: protos.TypeSystem_PG,
1494+
SoftDeleteColName: "",
1495+
SyncedAtColName: "",
1496+
}
1497+
1498+
env := ExecutePeerflow(s.t, tc, config)
1499+
SetupCDCFlowStatusQuery(s.t, env, config)
1500+
1501+
// Verify initial load replicated the child table rows despite FK constraint on destination
1502+
EnvWaitFor(s.t, env, 3*time.Minute, "wait for child table initial load", func() bool {
1503+
return s.comparePGTables(srcTableName2, dstTableName2, "id,parent_id,val") == nil
1504+
})
1505+
1506+
// Insert more rows via CDC path, again only into child table bypassing FK
1507+
cdcTx, err := s.Conn().Begin(s.t.Context())
1508+
EnvNoError(s.t, env, err)
1509+
_, err = cdcTx.Exec(s.t.Context(), "SET LOCAL session_replication_role = 'replica'")
1510+
EnvNoError(s.t, env, err)
1511+
for i := range 5 {
1512+
_, err = cdcTx.Exec(s.t.Context(), fmt.Sprintf(
1513+
`INSERT INTO %s(parent_id, val) VALUES ($1, $2)`, srcTableName2), int64(i+200), fmt.Sprintf("cdc_child_val_%d", i))
1514+
EnvNoError(s.t, env, err)
1515+
}
1516+
EnvNoError(s.t, env, cdcTx.Commit(s.t.Context()))
1517+
1518+
EnvWaitFor(s.t, env, 3*time.Minute, "wait for child table cdc", func() bool {
1519+
return s.comparePGTables(srcTableName2, dstTableName2, "id,parent_id,val") == nil
1520+
})
1521+
1522+
env.Cancel(s.t.Context())
1523+
RequireEnvCanceled(s.t, env)
1524+
}

0 commit comments

Comments
 (0)