Skip to content

Commit cdac3a9

Browse files
fix: skip replica-identity validation for sync-only data-sync (#1363)
`data_sync` validates that every published table has a usable replica identity (a primary key or a `REPLICA IDENTITY` index) before it starts copying. This prevents all data syncs, including `--sync-only`. That requirement only applies to the streaming phase, which uses the replica identity to build the per-row `UPDATE`/`DELETE` filters. The initial `COPY` never uses it, and a `--sync-only` run never streams. ## Change Add a `require_replica_identity` flag to `Publisher::data_sync` (threaded through `Orchestrator::data_sync` and `CopyDataTask`). `ReshardTask` sets it to `!sync_only`: - **Online** (default): unchanged — tables are still validated up front, before the potentially multi-hour copy. - **Sync-only**: validation is skipped, so tables without a primary key or replica identity can be copied. Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bafac9 commit cdac3a9

4 files changed

Lines changed: 93 additions & 16 deletions

File tree

pgdog/src/api/copy_data.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ use crate::backend::replication::logical::orchestrator::Orchestrator;
1515
#[display("copy_data {orchestrator}")]
1616
pub(crate) struct CopyDataTask {
1717
pub orchestrator: Orchestrator,
18+
/// Require a usable replica identity per table. Only streaming needs it,
19+
/// so a sync-only migration passes `false`. See `Publisher::data_sync`.
20+
pub require_replica_identity: bool,
1821
}
1922

2023
impl Task for CopyDataTask {
@@ -33,7 +36,9 @@ impl Task for CopyDataTask {
3336
return Err(Error::DataSyncAborted);
3437
}
3538

36-
orchestrator.data_sync(&token).await?;
39+
orchestrator
40+
.data_sync(&token, self.require_replica_identity)
41+
.await?;
3742

3843
Ok(orchestrator)
3944
}

pgdog/src/api/resharding.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,13 @@ impl Task for ReshardTask {
9999
if !self.replicate_only {
100100
ctx.set_status(ReshardStatus::SyncingData);
101101
orchestrator = ctx
102-
.run(CopyDataTask::builder().orchestrator(orchestrator).build())
102+
.run(
103+
CopyDataTask::builder()
104+
.orchestrator(orchestrator)
105+
// Only streaming needs replica identity, not a sync-only copy.
106+
.require_replica_identity(!self.sync_only)
107+
.build(),
108+
)
103109
.await?;
104110
}
105111

pgdog/src/backend/replication/logical/orchestrator.rs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -147,14 +147,23 @@ impl Orchestrator {
147147
Ok(())
148148
}
149149

150-
pub(crate) async fn data_sync(&self, cancel: &CancellationToken) -> Result<(), Error> {
150+
pub(crate) async fn data_sync(
151+
&self,
152+
cancel: &CancellationToken,
153+
require_replica_identity: bool,
154+
) -> Result<(), Error> {
151155
let mut publisher = self.publisher.lock().await;
152156

153157
orchestrator_state(OrchestratorState::DataSync);
154158
// Run data sync for all tables in parallel using multiple replicas,
155159
// if available.
156160
publisher
157-
.data_sync(&self.source, &self.destination, cancel)
161+
.data_sync(
162+
&self.source,
163+
&self.destination,
164+
cancel,
165+
require_replica_identity,
166+
)
158167
.await?;
159168

160169
Ok(())

pgdog/src/backend/replication/logical/publisher/publisher_impl.rs

Lines changed: 69 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -382,21 +382,25 @@ impl Publisher {
382382
source: &Cluster,
383383
dest: &Cluster,
384384
cancel: &CancellationToken,
385+
require_replica_identity: bool,
385386
) -> Result<(), Error> {
386387
// Fetch schema and column metadata first — valid() depends on it.
387388
self.sync_tables(true, source, dest).await?;
388389

389-
// Validate all tables support replication before committing to
390-
// what can be a multi-hour copy. A table with no primary key or
391-
// unique replica-identity index cannot be replicated correctly.
392-
let validation_errors: Vec<_> = self
393-
.tables
394-
.values()
395-
.flat_map(|t| t.iter())
396-
.filter_map(|t| t.valid().err())
397-
.collect();
390+
// Validate replica identity up front, before a potentially multi-hour
391+
// copy. Only streaming consumes it (to build the per-row UPDATE/DELETE
392+
// filters); the initial COPY does not — so a sync-only run skips the
393+
// gate and can copy tables that lack an identity.
394+
if require_replica_identity {
395+
let validation_errors: Vec<_> = self
396+
.tables
397+
.values()
398+
.flat_map(|t| t.iter())
399+
.filter_map(|t| t.valid().err())
400+
.collect();
398401

399-
ensure_validation!(validation_errors);
402+
ensure_validation!(validation_errors);
403+
}
400404

401405
// Create replication slots only after validation passes — a slot
402406
// created before valid() would be orphaned on validation errors.
@@ -646,7 +650,7 @@ mod test {
646650

647651
// Validation must fire before the copy begins.
648652
let result = publisher
649-
.data_sync(&source, &dest, &CancellationToken::new())
653+
.data_sync(&source, &dest, &CancellationToken::new(), true)
650654
.await;
651655

652656
let err = result.expect_err("data_sync must fail for a publication with no-pk tables");
@@ -676,6 +680,59 @@ mod test {
676680
}
677681
}
678682

683+
/// A sync-only copy (`require_replica_identity = false`) must not reject a
684+
/// table that lacks a replica identity.
685+
///
686+
/// Asserted without running the copy: the gate runs before the first
687+
/// cancellation check, so a pre-cancelled token returns `DataSyncAborted`
688+
/// (aborted at slot creation, past the skipped gate) — a `TableValidation`
689+
/// error here would mean the gate wrongly fired.
690+
#[tokio::test]
691+
async fn data_sync_skips_replica_identity_validation_when_not_required() {
692+
crate::logger();
693+
694+
let mut server = test_replication_server().await;
695+
for ddl in &[
696+
"CREATE TABLE IF NOT EXISTS publication_test_sync_only_no_pk (data TEXT NOT NULL)",
697+
"DROP PUBLICATION IF EXISTS publication_sync_only_no_pk",
698+
"CREATE PUBLICATION publication_sync_only_no_pk FOR TABLE publication_test_sync_only_no_pk",
699+
] {
700+
server.execute(*ddl).await.unwrap();
701+
}
702+
703+
let source = Cluster::new_test(&config());
704+
source.launch();
705+
let dest = Cluster::new_test(&config());
706+
707+
let mut publisher = Publisher::new(
708+
"publication_sync_only_no_pk",
709+
QueryParserEngine::default(),
710+
"sync_only_no_pk_slot".into(),
711+
);
712+
713+
let cancel = CancellationToken::new();
714+
cancel.cancel();
715+
let result = publisher.data_sync(&source, &dest, &cancel, false).await;
716+
717+
assert!(
718+
matches!(result, Err(Error::DataSyncAborted)),
719+
"sync-only copy must skip replica-identity validation and abort at slot \
720+
creation, not fail validation; got: {result:?}"
721+
);
722+
assert!(
723+
publisher.slots.is_empty(),
724+
"the cancelled token aborts before any slot is created"
725+
);
726+
727+
source.shutdown();
728+
for ddl in &[
729+
"DROP PUBLICATION IF EXISTS publication_sync_only_no_pk",
730+
"DROP TABLE IF EXISTS publication_test_sync_only_no_pk",
731+
] {
732+
server.execute(*ddl).await.unwrap();
733+
}
734+
}
735+
679736
/// `REPLICA IDENTITY NOTHING` must be rejected at `data_sync` time,
680737
/// before any replication slot is created. This test executes against
681738
/// a real Postgres instance so it validates the full metadata-fetch + valid() path.
@@ -704,7 +761,7 @@ mod test {
704761
);
705762

706763
let result = publisher
707-
.data_sync(&source, &dest, &CancellationToken::new())
764+
.data_sync(&source, &dest, &CancellationToken::new(), true)
708765
.await;
709766

710767
let err = result.expect_err("data_sync must fail for REPLICA IDENTITY NOTHING table");

0 commit comments

Comments
 (0)