Multi-host: a host that accepts TCP but never responds is never marked offline, so every new connection waits for the full Timeout
Maintainer antworten meist innerhalb von 6 Tagen
Bewertung
- Schwierigkeit
- 4/5
- Geschätzter Aufwand
- 3-5 Tage
- Anfängerfreundlichkeit
- 22/100
Rechercherichtung
The bug is in the connector state handling: start with the read timeout in src/Npgsql/Internal/NpgsqlReadBuffer.cs, then Break and Open in src/Npgsql/Internal/NpgsqlConnector.cs, and IsPreferred in src/Npgsql/NpgsqlMultiHostDataSource.cs. Run the repro program against a silent TCP listener first host to confirm every open waits the full Timeout. Done means a host that accepts TCP but never responds is marked offline after one failed open, so later opens skip it and return quickly.
Vom Indexierungsmodell aus dem Issue-Text verfasst.
Beschreibung
Steps to reproduce
- Start a PostgreSQL instance:
docker run -d -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:17 - Run the program below. It opens a local TCP listener that accepts connections but never sends anything (simulating a frozen PostgreSQL host), and puts it first in a multi-host connection string. For comparison, it runs the same loop with a port that refuses connections.
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="10.0.3" />
</ItemGroup>
</Project>
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using Npgsql;
// A real PostgreSQL, e.g.: docker run -d -e POSTGRES_PASSWORD=postgres -p 55432:5432 postgres:17
const string RealHost = "127.0.0.1:55432";
// An unresponsive server: accepts TCP connections but never sends anything back
// (simulates a hung/frozen PostgreSQL host).
var silent = new TcpListener(IPAddress.Loopback, 0);
silent.Start();
var silentPort = ((IPEndPoint)silent.LocalEndpoint).Port;
var accepted = new List<TcpClient>();
_ = Task.Run(async () => { while (true) accepted.Add(await silent.AcceptTcpClientAsync()); });
// A port nobody listens on: connection is refused immediately.
var closed = new TcpListener(IPAddress.Loopback, 0);
closed.Start();
var closedPort = ((IPEndPoint)closed.LocalEndpoint).Port;
closed.Stop();
Console.WriteLine($"Npgsql {typeof(NpgsqlConnection).Assembly.GetName().Version}");
await Run("first host refuses connections", $"127.0.0.1:{closedPort}");
await Run("first host accepts TCP but never responds", $"127.0.0.1:{silentPort}");
static async Task Run(string title, string badHost)
{
Console.WriteLine($"\n== {title}");
var cs = $"Host={badHost},{RealHost};Username=postgres;Password=postgres;Timeout=3";
await using var dataSource = NpgsqlDataSource.Create(cs);
for (var i = 1; i <= 5; i++)
{
var sw = Stopwatch.StartNew();
await using var conn = await dataSource.OpenConnectionAsync();
Console.WriteLine($"open #{i}: {sw.ElapsedMilliseconds,5} ms -> {conn.Host}:{conn.Port}");
}
}
The issue
Output:
Npgsql 10.0.3.0
== first host refuses connections
open #1: 2096 ms -> 127.0.0.1:55432
open #2: 0 ms -> 127.0.0.1:55432
open #3: 0 ms -> 127.0.0.1:55432
open #4: 0 ms -> 127.0.0.1:55432
open #5: 0 ms -> 127.0.0.1:55432
== first host accepts TCP but never responds
open #1: 3017 ms -> 127.0.0.1:55432
open #2: 3014 ms -> 127.0.0.1:55432
open #3: 3000 ms -> 127.0.0.1:55432
open #4: 3007 ms -> 127.0.0.1:55432
open #5: 3033 ms -> 127.0.0.1:55432
When the first host refuses the connection, it is marked offline and later opens go straight to the healthy host (using the idle pooled connection).
When the first host accepts the TCP connection but does not respond, it is never marked offline. Its state stays Unknown, which is treated as preferred, it has no idle connectors, so every open attempts a new physical connection to it and waits for the full Timeout before falling back to the healthy host, even though an idle connection to the healthy host is in the pool. Host Recheck Seconds has no effect, since there is no cached state to expire.
On 10.0.2 I observed the same with TargetSessionAttributes ReadOnly, PreferStandby and Any (via NpgsqlMultiHostDataSource.WithTargetSession) against a 3-node Patroni cluster with one node frozen (docker pause). With the default Timeout=15, every query waits 15 s for as long as the host stays unresponsive. We hit this in production during a Patroni failover: the old primary stopped responding and all read-only queries were delayed by 15 s until the node was fixed; restarting the application did not help.
What I think is happening
- The startup/auth read times out in
NpgsqlReadBufferand callsconnector.Break(...)withoutmarkHostAsOfflineOnConnecting(NpgsqlReadBuffer.cs#L360). The connector is stillConnecting, so the host is not marked offline (NpgsqlConnector.cs#L2501), but the connector becomesBroken. Openthen catches the exception and callsBreak(e, markHostAsOfflineOnConnecting: true)(NpgsqlConnector.cs#L585), which would mark the host offline, but it returns early because the connector is alreadyBroken(NpgsqlConnector.cs#L2464).- The host stays
Unknown, whichIsPreferredaccepts (NpgsqlMultiHostDataSource.cs#L129), so it is tried again on the next open.
The same structure is still present on main (b35800b).
Possibly related: #5789, #6483 (those are about the delay after a host was marked offline and Host Recheck Seconds expires; here the host is never marked offline at all).
Further technical details
Npgsql version: 10.0.3 (also reproduced with 10.0.2)
PostgreSQL version: 17.11
Operating system: Windows 11 (client), PostgreSQL in Docker (Linux)
Other details about my project setup: .NET 10, multi-host connection string, no load balancing.
- Vorherrschende Sprache
- C#
- Sterne
- 3.7k
- Forks
- 890
- Ø Merge
- 4 T. 11 Std.
- Gemergte PRs (30 T.)
- 19
Entwicklungsumgebung
Startet den Dev-Container des Projekts im Browser, mit Ihrem eigenen GitHub-Konto.
- Kein Dockerfile und keine Docker-Compose-Datei
- Keine Pull-Request-Vorlage
- Kein Beitragsleitfaden
Erste Schritte
- Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
- Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
- Forken Sie das Repository und arbeiten Sie in einem Branch.
- Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.
Mehr aus npgsql/npgsql
-
Writing a decimal as numeric allocates a short[9] per valueEvtl. vergeben @tamasprincz97-dot hat das vor 13 Tagen übernommen. Offen
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 89/100
Maintainer antworten meist innerhalb von 6 Tagen
-
Reading a large interval as TimeSpan returns a wrong value instead of throwingEvtl. vergeben @Mr444-dev hat das vor 7 Tagen übernommen. Offen
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 66/100
Maintainer antworten meist innerhalb von 6 Tagen
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 68/100
Maintainer antworten meist innerhalb von 6 Tagen
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 70/100
npgsql/npgsql#6569 · 2 Kommentare ·
Maintainer antworten meist innerhalb von 6 Tagen
-
ReadReplicationSlot returns the slot name as passed, not the one the server readEvtl. vergeben @Brar hat das vor 8 Tagen übernommen. Offenreplication
npgsql/npgsql#6735 · 1 zugewiesene Person ·
Maintainer antworten meist innerhalb von 6 Tagen
Ähnliche Issues
-
Issue-Bug Needs-Triage
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 78/100
Maintainer antworten meist innerhalb von 2 Tagen
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 66/100
JiaFeiMiao-K-Cat/DiscImageStudio#281 ·
Maintainer antworten meist innerhalb von 1 Tag
-
area/access-management Frontend onboarding candidate :handshake:
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 65/100
Altinn/altinn-auth#4941 ·
Maintainer antworten meist innerhalb von 1 Tag
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 78/100
aws/aws-sdk-net#4586 ·
Maintainer antworten meist innerhalb von 1 Tag
-
Schwierigkeit 2/5 1-3 Stunden Anfängerfreundlichkeit 82/100
Maintainer antworten meist innerhalb von 1 Tag