Repository navigation
Conversation
PgTestCluster creates PostgreSQL clusters in temporary directories with the binaries that NPGSQL_TEST_PG_BIN points to (initdb, pg_ctl, pg_basebackup): a primary that allows physical and logical replication, streaming standbys of it, promotion and shutdown in any mode. This covers tests that can't run against the shared test server, like timeline switches and server shutdowns. Tests using it are ignored when the variable isn't set, so CI is unaffected. The clusters listen on 127.0.0.1 only (no Unix sockets, whose default directory isn't writable for unprivileged users on Debian-based builds), pick a free port right before each start, retry when another process took it, and limit every step to well below CI's 30 seconds per test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
roji
left a comment
There was a problem hiding this comment.
Recommendation
The direction is justified, but fix the primary restart behavior and strengthen the smoke test before merging. Keep this as a small test helper rather than a general cluster-management framework.
Problem and expected approach
The existing TimelineHistory test is explicit because a freshly initialized shared server has no timeline history. Promotion and shutdown tests genuinely need an isolated server. I would expect one internal C# helper, roughly a couple hundred test-only lines, using PostgreSQL's own tools, with primary-to-standby replay, promotion, and cleanup coverage. No shipped API or public documentation changes are needed; documenting the opt-in binary directory is sufficient.
Fit with that scope
The helper largely matches that approach: temporary data directories, native tools, non-pooled connections, bounded process execution, and opt-in execution. Its basic use is straightforward:
await using var primary = await PgTestCluster.StartPrimary();
await using var standby = await primary.StartStandby();
await standby.Promote();Gaps
The inline comments cover a reproduced correctness issue: Stop()/Start() changes the primary port without updating existing standbys, so replication does not resume. The smoke test also never checks replay; a disconnected standby can still report recovery and promote successfully.
This PR supplies infrastructure, not actual Npgsql replication coverage yet. A small follow-up should use it for the existing timeline test. My direct probe of IdentifySystem() followed by TimelineHistory(identity.Timeline) on the promoted standby threw InvalidCastException (String to Byte[]) at ReplicationConnection.cs:341. That cast already exists in the base commit: it demonstrates the coverage gap, not a product defect introduced by this PR, and should be fixed separately.
Manual validation
I compiled a separate console client against the unmodified helper and this checkout's Npgsql, using PostgreSQL 18.4. I created a primary and standby, inserted a row on the primary and observed it on the standby, reproduced the restart failure, promoted the standby to timeline 2, exercised the timeline API, restarted after immediate shutdown, and verified disposal removed the temporary cluster root. The added NUnit smoke test also passed when enabled and was skipped when the environment variable was absent. Local SDK constraints required an installed .NET 11 preview SDK; no repository files were changed.
Posted by copilot
| { | ||
| // We choose the port right before starting so that nothing else grabs it in between. pg_basebackup writes | ||
| // primary_conninfo to postgresql.auto.conf, so the port goes there too; the last setting wins. | ||
| Port = GetFreePort(); |
There was a problem hiding this comment.
[P2] Keep the primary endpoint stable across restarts
pg_basebackup --write-recovery-conf records the primary's current port in the standby's primary_conninfo, but every Start() chooses another port. With a running standby, await primary.Stop(); await primary.Start(); therefore leaves it trying to reconnect to the old port indefinitely.
I reproduced this with PostgreSQL 18.4: the primary moved from port 54249 to 54262, the standby still targeted 54249, pg_stat_wal_receiver was empty, and a second inserted row never reached the standby. Preserve the selected port across restarts rather than adding reconnection plumbing. If restarting is not intended to be supported, make Start() private instead of exposing this broken path.
Posted by copilot
| await using var standby = await primary.StartStandby(); | ||
|
|
||
| Assert.That(await IsInRecovery(primary), Is.False); | ||
| Assert.That(await IsInRecovery(standby), Is.True); |
There was a problem hiding this comment.
Please verify a primary write reaches the standby before promoting it. pg_is_in_recovery() only establishes recovery mode, not a working streaming connection: a disconnected standby can pass all the current assertions and still promote. One insert on the primary and a bounded wait for that row on the standby would exercise the main contract of StartStandby() without expanding the test much.
Posted by copilot
roji
left a comment
There was a problem hiding this comment.
See agentic review comments, approving without deeper review as this is a test infra only change.
There was a problem hiding this comment.
🟡 Changes recommended
Restarting an upstream can change its port without updating existing standbys, preventing replication from resuming.
1 open issue
What changed in this PR
Adds an opt-in PostgreSQL cluster helper for replication tests that require isolated servers rather than the shared test database.
Changes:
- Creates temporary primaries and streaming standbys with startup, promotion, shutdown, and cleanup support.
- Adds process timeouts, loopback-only connections, and port-conflict retries.
- Adds a standby promotion smoke test, skipped when
NPGSQL_TEST_PG_BINis unset.
| File | Description |
|---|---|
| test/Npgsql.Tests/Support/PgTestCluster.cs | Implements temporary PostgreSQL cluster lifecycle management. |
| test/Npgsql.Tests/Replication/PgTestClusterTests.cs | Tests primary and standby recovery states, promotion, and shutdown. |
Review effort: Balanced
| Port = GetFreePort(); | ||
| await File.AppendAllTextAsync(Path.Combine(DataDirectory, "postgresql.auto.conf"), $"\nport = {Port}\n"); |

PgTestCluster creates PostgreSQL clusters in temporary directories with the binaries that NPGSQL_TEST_PG_BIN points to (initdb, pg_ctl, pg_basebackup): a primary that allows physical and logical replication, streaming standbys of it, promotion and shutdown in any mode. This covers tests that can't run against the shared test server, like timeline switches and server shutdowns. Tests using it are ignored when the variable isn't set, so CI is unaffected.
The clusters listen on 127.0.0.1 only (no Unix sockets, whose default directory isn't writable for unprivileged users on Debian-based builds), pick a free port right before each start, retry when another process took it, and limit every step to well below CI's 30 seconds per test.