Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Occasional prepared statement already exist errors when using statement timeout #2223

Open
oschwald opened this issue Jan 8, 2025 · 1 comment
Labels

Comments

@oschwald
Copy link

oschwald commented Jan 8, 2025

Describe the bug

We have a web service where we set a statement_timeout on our connections to ensure that queries do not run for an extended period of time. As part of this web service, we have a large, generated query. We occasionally see errors such as the following:

ERROR: prepared statement "stmtcache_2b7087dc2ab6296b87fa5baa7f329541c23d17376faa03b7" already exists (SQLSTATE 42P05)

My hypothesis is that there is a statement timeout in the PREPARE but after Postgres has actually created the prepared statement. This causes pgx to end up in a state where the prepared statement isn't added to the statement cache but does exist for the connection in Postgres. I was able to reproduce this with the code below.

To Reproduce

The following sample code reliably produces the error with a local Postgres server for me. This probably could be simplified a bit further.

package main

import (
	"context"
	"database/sql"
	"errors"
	"fmt"
	"log"
	"math/rand"
	"os"

	"github.com/jackc/pgx/v5/pgconn"
	_ "github.com/jackc/pgx/v5/stdlib"
)

func main() {
	db, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}

	ctx := context.Background()

	conn, err := db.Conn(ctx)
	if err != nil {
		log.Fatal(err)
	}

	_, err = conn.ExecContext(ctx, "SET statement_timeout TO 1")
	if err != nil {
		log.Fatal(err)
	}

	p := "s"
	for {
		var v uint

		q := fmt.Sprintf("SELECT 1 AS %s", p)
		err := conn.QueryRowContext(
			ctx,
			q,
		).Scan(&v)
		if err != nil && !errors.Is(err, context.Canceled) {
			var pgErr *pgconn.PgError
			if errors.As(err, &pgErr) && pgErr.Code == "57014" {
				continue
			}
			log.Fatal(err)
		}
		p = randString(100000)
	}
}

var letterRunes = []rune("abAB")

func randString(n int) string {
	b := make([]rune, n)
	for i := range b {
		b[i] = letterRunes[rand.Intn(len(letterRunes))]
	}
	return string(b)
}

Expected behavior

No prepared statement already exists error.

Actual behavior

ERROR: prepared statement "stmtcache_087f9f36620ff21723cd288c17d7806ba6fdd9b160f73186" already exists (SQLSTATE 42P05)

Version

  • Go: go version go1.23.4 linux/amd64
  • PostgreSQL: PostgreSQL 16.6 (Ubuntu 16.6-1.pgdg24.10+1) on x86_64-pc-linux-gnu, compiled by gcc (Ubuntu 14.2.0-4ubuntu2) 14.2.0, 64-bit
  • pgx: v5.7.2
@oschwald oschwald added the bug label Jan 8, 2025
@jackc
Copy link
Owner

jackc commented Jan 11, 2025

My hypothesis is that there is a statement timeout in the PREPARE but after Postgres has actually created the prepared statement. This causes the pgx to end up in a state where the prepared statement isn't added to the statement cache but does exist for the connection in Postgres. I was able to reproduce this with the code below.

You are correct. Based on your example code I was able to build a reproduction case directly at the PostgreSQL protocol layer.

I send a Parse, sleep, and then send a Describe. The Parse succeeds and the Describe fails. Preparing and releasing statements are not transactional, so even though the extended protocol pseudo-transaction fails the prepare is not undone.

pgx and pgconn always send Parse and Describe together . pgx keeps a record of the statement description for each prepared statement.

In theory, I know how to solve this, but in practice it's tricky / messy enough I'm not sure. pgconn.PgConn.Prepare() could return a special error that denoted that Parse succeeded and Describe failed. Callers would detect that error and deallocate the statement on the server. Similar logic would need to be implemented for batches and pipeline mode. However, there are many contexts in which the prepare path can be called, so I'm not confident in this solution's robustness.

package main

import (
	"context"
	"fmt"
	"log"
	"os"
	"time"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgproto3"
)

func main() {
	ctx := context.Background()

	conn, err := pgx.Connect(ctx, os.Getenv("DATABASE_URL"))
	if err != nil {
		log.Fatal(err)
	}
	defer conn.Close(ctx)

	_, err = conn.Exec(ctx, "SET statement_timeout TO 1")
	if err != nil {
		log.Fatal(err)
	}

	conn.PgConn().Frontend().SendParse(&pgproto3.Parse{Name: "ps1", Query: "select 1", ParameterOIDs: nil})
	err = conn.PgConn().Frontend().Flush()
	if err != nil {
		log.Fatal(err)
	}
	time.Sleep(1 * time.Millisecond)
	conn.PgConn().Frontend().SendDescribe(&pgproto3.Describe{ObjectType: 'S', Name: "ps1"})
	time.Sleep(1 * time.Millisecond)
	conn.PgConn().Frontend().SendSync(&pgproto3.Sync{})
	err = conn.PgConn().Frontend().Flush()
	if err != nil {
		log.Fatal(err)
	}

	for range 3 {
		msg, err := conn.PgConn().Frontend().Receive()
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%#v\n", msg)
	}

	conn.PgConn().Frontend().SendParse(&pgproto3.Parse{Name: "ps1", Query: "select 1", ParameterOIDs: nil})
	err = conn.PgConn().Frontend().Flush()
	if err != nil {
		log.Fatal(err)
	}
	time.Sleep(1 * time.Millisecond)
	conn.PgConn().Frontend().SendDescribe(&pgproto3.Describe{ObjectType: 'S', Name: "ps1"})
	time.Sleep(1 * time.Millisecond)
	conn.PgConn().Frontend().SendSync(&pgproto3.Sync{})
	err = conn.PgConn().Frontend().Flush()
	if err != nil {
		log.Fatal(err)
	}

	for range 2 {
		msg, err := conn.PgConn().Frontend().Receive()
		if err != nil {
			log.Fatal(err)
		}
		fmt.Printf("%#v\n", msg)
	}
}
jack@glados ~/dev/pgx_issues/pgx-2223 ±master⚡ » gor .
&pgproto3.ParseComplete{}
&pgproto3.ErrorResponse{Severity:"ERROR", SeverityUnlocalized:"ERROR", Code:"57014", Message:"canceling statement due to statement timeout", Detail:"", Hint:"", Position:0, InternalPosition:0, InternalQuery:"", Where:"", SchemaName:"", TableName:"", ColumnName:"", DataTypeName:"", ConstraintName:"", File:"postgres.c", Line:3356, Routine:"ProcessInterrupts", UnknownFields:map[uint8]string(nil)}
&pgproto3.ReadyForQuery{TxStatus:0x49}
&pgproto3.ErrorResponse{Severity:"ERROR", SeverityUnlocalized:"ERROR", Code:"42P05", Message:"prepared statement \"ps1\" already exists", Detail:"", Hint:"", Position:0, InternalPosition:0, InternalQuery:"", Where:"", SchemaName:"", TableName:"", ColumnName:"", DataTypeName:"", ConstraintName:"", File:"prepare.c", Line:415, Routine:"StorePreparedStatement", UnknownFields:map[uint8]string(nil)}
&pgproto3.ReadyForQuery{TxStatus:0x49}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

2 participants