Skip to content

Pgpool closes a frontend connection after a Describe of a nonexistent named prepared statement #169

Description

@apkipa

Pgpool-II version

4.8devel (787313f)

Description

A statement-level Describe for a nonexistent named prepared statement is a well-formed extended-protocol request with a defined PostgreSQL error response. PostgreSQL returns SQLSTATE 26000; in a pipeline segment it ignores later commands until Sync, and the client observes PIPELINE_ABORTED followed by PIPELINE_SYNC. Pgpool instead terminates the frontend connection.

Pgpool dispatches frontend message D directly to Describe(). In its statement branch, Describe() looks up a prior SQL PREPARE or protocol Parse in Pgpool's sent-message cache:

/* Prepared Statement */
if (*contents == 'S')
{
    /* <snip> */
    msg = pool_get_sent_message('Q', contents + 1, POOL_SENT_MESSAGE_CREATED);
    if (!msg)
        msg = pool_get_sent_message('P', contents + 1, POOL_SENT_MESSAGE_CREATED);
    if (!msg)
        ereport(FATAL,
                (return_code(2),
                 errmsg("unable to execute Describe"),
                 errdetail("unable to get the parse message")));
}

The reproducer has neither a prior SQL PREPARE nor a protocol Parse for missing_prepared, so neither lookup finds a cache entry. Pgpool raises its local FATAL before PostgreSQL can perform the corresponding statement lookup, so the client receives neither the backend error nor the pipeline recovery results for the later command and Sync.

Reproduce

  1. Start PostgreSQL 16 and Pgpool-II.
  2. Run the following with the direct and Pgpool DSNs supplied by the environment:
#!/usr/bin/env python3
import select
import time

from psycopg.pq import DiagnosticField, ExecStatus, PGconn


def result_name(result):
    name = ExecStatus(result.status).name
    if result.status == ExecStatus.FATAL_ERROR:
        code = result.error_field(DiagnosticField.SQLSTATE) or b"?"
        name += f"[{code.decode(errors='replace')}]"
    return name


def drain(conn):
    results = []
    deadline = time.monotonic() + 5
    while time.monotonic() < deadline:
        try:
            conn.flush()
            conn.consume_input()
        except Exception as exc:
            return results + [f"CONNECTION_LOST: {exc}"]
        while (result := conn.get_result()) is not None:
            results.append(result_name(result))
        if results and results[-1] == "PIPELINE_SYNC":
            return results
        select.select([conn.socket], [], [], 0.05)
    return results + ["TIMEOUT"]


def run(label, dsn):
    conn = PGconn.connect(dsn.encode())
    try:
        if conn.status != 0:
            raise RuntimeError(conn.error_message.decode(errors="replace"))
        conn.nonblocking = 1
        conn.enter_pipeline_mode()
        conn.send_query_params(b"SELECT 1", None)
        conn.send_describe_prepared(b"missing_prepared")
        conn.send_query_params(b"SELECT 2", None)
        conn.pipeline_sync()
        print(f"{label}: {drain(conn)}")
    except Exception as exc:
        print(f"{label}: {type(exc).__name__}: {exc}")
    finally:
        conn.finish()


run("direct", DIRECT_DSN)
run("pgpool", PGPOOL_DSN)

Expected behavior

With the same pipeline segment, the client should observe FATAL_ERROR[26000] for Describe, PIPELINE_ABORTED for SELECT 2, and PIPELINE_SYNC at the Sync boundary.

Actual behavior

Direct PostgreSQL prints:

direct: ['TUPLES_OK', 'FATAL_ERROR[26000]', 'PIPELINE_ABORTED', 'PIPELINE_SYNC']

Pgpool prints:

pgpool: ['TUPLES_OK', 'FATAL_ERROR[XX000]', 'CONNECTION_LOST']

Pgpool emits its own XX000 error and closes the frontend connection before the Describe reaches PostgreSQL. The client receives neither PIPELINE_ABORTED nor PIPELINE_SYNC.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions