Multi-host: a host that accepts TCP but never responds is never marked offline, so every new connection waits for the full Timeout
Los mantenedores suelen responder en 6 días
Evaluación
- Dificultad
- 4/5
- Tiempo estimado
- 3-5 días
- Aptitud para principiantes
- 22/100
Línea de trabajo
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.
Escrito por el modelo de indexación a partir del texto del issue.
Descripción
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.
- Lenguaje dominante
- C#
- Estrellas
- 3.7k
- Forks
- 890
- Merge medio
- 4 d 11 h
- PR fusionados (30 d)
- 19
Preparar el entorno
Inicia el contenedor de desarrollo del proyecto en tu navegador, con tu propia cuenta de GitHub.
- Sin Dockerfile ni archivo de Docker Compose
- Sin plantilla de pull request
- Sin guía de contribución
Primeros pasos
- Lee el issue completo y luego la guía de contribución del proyecto.
- Comenta en el issue que vas a ocuparte — evita que dos personas hagan lo mismo.
- Haz un fork del repositorio y trabaja en una rama.
- Abre un pull request que haga referencia al número del issue.
Más de npgsql/npgsql
-
Writing a decimal as numeric allocates a short[9] per valuePosiblemente ocupada @tamasprincz97-dot la tomó hace 13 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 89/100
Los mantenedores suelen responder en 6 días
-
Reading a large interval as TimeSpan returns a wrong value instead of throwingPosiblemente ocupada @Mr444-dev la tomó hace 7 días. Abierto
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
Los mantenedores suelen responder en 6 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 68/100
Los mantenedores suelen responder en 6 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 70/100
npgsql/npgsql#6569 · 2 comentarios ·
Los mantenedores suelen responder en 6 días
-
ReadReplicationSlot returns the slot name as passed, not the one the server readPosiblemente ocupada @Brar la tomó hace 8 días. Abiertoreplication
npgsql/npgsql#6735 · 1 asignado ·
Los mantenedores suelen responder en 6 días
Todos los issues de npgsql/npgsql
Issues similares
-
Issue-Bug Needs-Triage
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
Los mantenedores suelen responder en 2 días
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 66/100
JiaFeiMiao-K-Cat/DiscImageStudio#281 ·
Los mantenedores suelen responder en 1 día
-
area/access-management Frontend onboarding candidate :handshake:
Dificultad 2/5 1-3 horas Aptitud para principiantes 65/100
Altinn/altinn-auth#4941 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 78/100
aws/aws-sdk-net#4586 ·
Los mantenedores suelen responder en 1 día
-
Dificultad 2/5 1-3 horas Aptitud para principiantes 82/100
Los mantenedores suelen responder en 1 día