Skip to content

Commit fa2ae69

Browse files
committed
Handle for preparing statements that fail during the Describe phase
A failure that occurred after a successful Parse could leave a prepared statement on the server that must be deallocated before re-preparing. Also, introduce a testing utility faultyconn.Conn that can be used to intercept and modify frontend messages. This is used to induce difficult difficult to reproduce timing and protocol errors in tests.
1 parent 28bff80 commit fa2ae69

7 files changed

Lines changed: 283 additions & 11 deletions

File tree

batch_test.go

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import (
44
"context"
55
"errors"
66
"fmt"
7+
"io"
8+
"net"
79
"os"
810
"testing"
911
"time"
1012

1113
"github.com/jackc/pgx/v5"
14+
"github.com/jackc/pgx/v5/internal/faultyconn"
1215
"github.com/jackc/pgx/v5/pgconn"
16+
"github.com/jackc/pgx/v5/pgproto3"
1317
"github.com/jackc/pgx/v5/pgxtest"
1418
"github.com/stretchr/testify/assert"
1519
"github.com/stretchr/testify/require"
@@ -1060,6 +1064,71 @@ func TestSendBatchStatementTimeout(t *testing.T) {
10601064

10611065
}
10621066

1067+
func TestSendBatchHandlesTimeoutBetweenParseAndDescribe(t *testing.T) {
1068+
// Not parallel because it is a timing sensitive test.
1069+
1070+
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
1071+
defer cancel()
1072+
1073+
var faultyConn *faultyconn.Conn
1074+
faultyConnTestRunner := pgxtest.DefaultConnTestRunner()
1075+
faultyConnTestRunner.CreateConfig = func(ctx context.Context, t testing.TB) *pgx.ConnConfig {
1076+
config, err := pgx.ParseConfig(os.Getenv("PGX_TEST_DATABASE"))
1077+
require.NoError(t, err)
1078+
config.AfterNetConnect = func(ctx context.Context, config *pgconn.Config, conn net.Conn) (net.Conn, error) {
1079+
faultyConn = faultyconn.New(conn)
1080+
return faultyConn, nil
1081+
}
1082+
return config
1083+
}
1084+
1085+
// Only need to test modes that use Parse/Describe.
1086+
extendedQueryModes := []pgx.QueryExecMode{
1087+
pgx.QueryExecModeCacheStatement,
1088+
pgx.QueryExecModeCacheDescribe,
1089+
pgx.QueryExecModeDescribeExec,
1090+
pgx.QueryExecModeExec,
1091+
}
1092+
1093+
pgxtest.RunWithQueryExecModes(ctx, t, faultyConnTestRunner, extendedQueryModes, func(ctx context.Context, t testing.TB, conn *pgx.Conn) {
1094+
_, err := conn.Exec(ctx, "set statement_timeout = '100ms'")
1095+
require.NoError(t, err)
1096+
1097+
batch := &pgx.Batch{}
1098+
batch.Queue("select 1")
1099+
batch.Queue("select 2")
1100+
1101+
faultyConn.HandleFrontendMessage = func(backendWriter io.Writer, msg pgproto3.FrontendMessage) error {
1102+
if _, ok := msg.(*pgproto3.Describe); ok {
1103+
time.Sleep(200 * time.Millisecond)
1104+
}
1105+
buf, err := msg.Encode(nil)
1106+
if err != nil {
1107+
return err
1108+
}
1109+
_, err = backendWriter.Write(buf)
1110+
return err
1111+
}
1112+
1113+
err = conn.SendBatch(ctx, batch).Close()
1114+
require.Error(t, err)
1115+
var pgErr *pgconn.PgError
1116+
require.True(t, errors.As(err, &pgErr))
1117+
require.Equal(t, "57014", pgErr.Code)
1118+
1119+
faultyConn.HandleFrontendMessage = nil
1120+
1121+
_, err = conn.Exec(ctx, "set statement_timeout = default")
1122+
require.NoError(t, err)
1123+
1124+
batch = &pgx.Batch{}
1125+
batch.Queue("select 1")
1126+
batch.Queue("select 2")
1127+
err = conn.SendBatch(ctx, batch).Close()
1128+
require.NoError(t, err)
1129+
})
1130+
}
1131+
10631132
func ExampleConn_SendBatch() {
10641133
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
10651134
defer cancel()

conn.go

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,12 @@ func (cc *ConnConfig) ConnString() string { return cc.connString }
6565
// Conn is a PostgreSQL connection handle. It is not safe for concurrent usage. Use a connection pool to manage access
6666
// to multiple database connections from multiple goroutines.
6767
type Conn struct {
68-
pgConn *pgconn.PgConn
69-
config *ConnConfig // config used when establishing this connection
70-
preparedStatements map[string]*pgconn.StatementDescription
71-
statementCache stmtcache.Cache
72-
descriptionCache stmtcache.Cache
68+
pgConn *pgconn.PgConn
69+
config *ConnConfig // config used when establishing this connection
70+
preparedStatements map[string]*pgconn.StatementDescription
71+
failedDescribeStatement string
72+
statementCache stmtcache.Cache
73+
descriptionCache stmtcache.Cache
7374

7475
queryTracer QueryTracer
7576
batchTracer BatchTracer
@@ -314,6 +315,14 @@ func (c *Conn) Close(ctx context.Context) error {
314315
// Prepare is idempotent; i.e. it is safe to call Prepare multiple times with the same name and sql arguments. This
315316
// allows a code path to Prepare and Query/Exec without concern for if the statement has already been prepared.
316317
func (c *Conn) Prepare(ctx context.Context, name, sql string) (sd *pgconn.StatementDescription, err error) {
318+
if c.failedDescribeStatement != "" {
319+
err = c.Deallocate(ctx, c.failedDescribeStatement)
320+
if err != nil {
321+
return nil, fmt.Errorf("failed to deallocate previously failed statement %q: %w", c.failedDescribeStatement, err)
322+
}
323+
c.failedDescribeStatement = ""
324+
}
325+
317326
if c.prepareTracer != nil {
318327
ctx = c.prepareTracer.TracePrepareStart(ctx, c, TracePrepareStartData{Name: name, SQL: sql})
319328
}
@@ -346,6 +355,10 @@ func (c *Conn) Prepare(ctx context.Context, name, sql string) (sd *pgconn.Statem
346355

347356
sd, err = c.pgConn.Prepare(ctx, psName, sql, nil)
348357
if err != nil {
358+
var pErr *pgconn.PrepareError
359+
if errors.As(err, &pErr) {
360+
c.failedDescribeStatement = psKey
361+
}
349362
return nil, err
350363
}
351364

conn_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import (
44
"bytes"
55
"context"
66
"database/sql"
7+
"io"
8+
"net"
79
"os"
810
"strings"
911
"sync"
1012
"testing"
1113
"time"
1214

1315
"github.com/jackc/pgx/v5"
16+
"github.com/jackc/pgx/v5/internal/faultyconn"
1417
"github.com/jackc/pgx/v5/pgconn"
18+
"github.com/jackc/pgx/v5/pgproto3"
1519
"github.com/jackc/pgx/v5/pgtype"
1620
"github.com/jackc/pgx/v5/pgxtest"
1721
"github.com/stretchr/testify/assert"
@@ -466,6 +470,67 @@ func TestPrepare(t *testing.T) {
466470
}
467471
}
468472

473+
// https://github.com/jackc/pgx/issues/2223
474+
func TestPrepareHandlesTimeoutBetweenParseAndDescribe(t *testing.T) {
475+
// Not parallel because it is a timing sensitive test.
476+
477+
config, err := pgx.ParseConfig(os.Getenv("PGX_TEST_DATABASE"))
478+
require.NoError(t, err)
479+
480+
var faultyConn *faultyconn.Conn
481+
config.AfterNetConnect = func(ctx context.Context, config *pgconn.Config, conn net.Conn) (net.Conn, error) {
482+
faultyConn = faultyconn.New(conn)
483+
return faultyConn, nil
484+
}
485+
486+
ctx := context.Background()
487+
conn, err := pgx.ConnectConfig(ctx, config)
488+
require.NoError(t, err)
489+
defer closeConn(t, conn)
490+
require.NotNil(t, faultyConn)
491+
492+
_, err = conn.Exec(ctx, "set statement_timeout = '100ms'")
493+
require.NoError(t, err)
494+
495+
faultyConn.HandleFrontendMessage = func(backendWriter io.Writer, msg pgproto3.FrontendMessage) error {
496+
if _, ok := msg.(*pgproto3.Describe); ok {
497+
time.Sleep(200 * time.Millisecond)
498+
}
499+
buf, err := msg.Encode(nil)
500+
if err != nil {
501+
return err
502+
}
503+
_, err = backendWriter.Write(buf)
504+
return err
505+
}
506+
507+
psd, err := conn.Prepare(ctx, "test", "select $1::varchar")
508+
var pgErr *pgconn.PgError
509+
require.ErrorAs(t, err, &pgErr)
510+
require.Equal(t, "57014", pgErr.Code)
511+
require.Nil(t, psd)
512+
513+
faultyConn.HandleFrontendMessage = nil
514+
515+
_, err = conn.Exec(ctx, "set statement_timeout = default")
516+
require.NoError(t, err)
517+
518+
var existsOnServer bool
519+
err = conn.QueryRow(
520+
ctx,
521+
"select exists(select 1 from pg_prepared_statements where name = 'test')",
522+
// Avoid using the prepared statement cache or it will clear the broken statement before we can check for its
523+
// existence.
524+
pgx.QueryExecModeExec,
525+
).Scan(&existsOnServer)
526+
require.NoError(t, err)
527+
require.True(t, existsOnServer)
528+
529+
psd, err = conn.Prepare(ctx, "test", "select $1::varchar")
530+
require.NoError(t, err)
531+
require.NotNil(t, psd)
532+
}
533+
469534
func TestPrepareBadSQLFailure(t *testing.T) {
470535
t.Parallel()
471536

internal/faultyconn/faultyconn.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package faultyconn
2+
3+
import (
4+
"bytes"
5+
"io"
6+
"net"
7+
"time"
8+
9+
"github.com/jackc/pgx/v5/pgproto3"
10+
)
11+
12+
// Conn is a wrapper for a net.Conn that allows inspection and modification of messages between a PostgreSQL client and
13+
// server. It is designed to be used in tests that use a *pgx.Conn or *pgconn.PgConn connected to a real PostgreSQL
14+
// server. Instead of mocking an entire server connection, this is used to specify and modify only the particular
15+
// aspects of a connection that are necessary. This can be easier to setup and is more true to real world conditions.
16+
//
17+
// It currently only supports handling frontend messages.
18+
type Conn struct {
19+
// HandleFrontendMessage is called for each frontend message received. It should use backendWriter to write to the
20+
// backend.
21+
HandleFrontendMessage func(backendWriter io.Writer, msg pgproto3.FrontendMessage) error
22+
23+
// TODO: Implement this if we need to handle backend messages.
24+
// HandleBackendMessage func(w io.Writer, msg pgproto3.BackendMessage) error
25+
26+
conn net.Conn
27+
fromFrontendBuf *bytes.Buffer
28+
fromBackendBuf *bytes.Buffer
29+
backend *pgproto3.Backend
30+
frontend *pgproto3.Frontend
31+
}
32+
33+
// NewConn creates a new Conn that proxies the messages sent to and from the given net.Conn. New can be used with
34+
// pgconn.Config.AfterNetConnect to wrap a net.Conn for testing purposes.
35+
func New(c net.Conn) *Conn {
36+
fromFrontendBuf := &bytes.Buffer{}
37+
fromBackendBuf := &bytes.Buffer{}
38+
39+
return &Conn{
40+
conn: c,
41+
fromFrontendBuf: fromFrontendBuf,
42+
fromBackendBuf: fromBackendBuf,
43+
backend: pgproto3.NewBackend(fromFrontendBuf, c),
44+
frontend: pgproto3.NewFrontend(fromBackendBuf, c),
45+
}
46+
}
47+
48+
func (c *Conn) Read(b []byte) (n int, err error) {
49+
return c.conn.Read(b)
50+
}
51+
52+
func (c *Conn) Write(b []byte) (n int, err error) {
53+
if c.HandleFrontendMessage == nil {
54+
return c.conn.Write(b)
55+
}
56+
57+
c.fromFrontendBuf.Write(b)
58+
59+
for {
60+
msg, err := c.backend.Receive()
61+
if err != nil {
62+
if err == io.ErrUnexpectedEOF {
63+
break
64+
}
65+
return len(b), err
66+
}
67+
68+
err = c.HandleFrontendMessage(c.conn, msg)
69+
if err != nil {
70+
return len(b), err
71+
}
72+
}
73+
74+
return len(b), nil
75+
}
76+
77+
func (c *Conn) Close() error {
78+
return c.conn.Close()
79+
}
80+
81+
func (c *Conn) LocalAddr() net.Addr {
82+
return c.conn.LocalAddr()
83+
}
84+
85+
func (c *Conn) RemoteAddr() net.Addr {
86+
return c.conn.RemoteAddr()
87+
}
88+
89+
func (c *Conn) SetDeadline(t time.Time) error {
90+
return c.conn.SetDeadline(t)
91+
}
92+
93+
func (c *Conn) SetReadDeadline(t time.Time) error {
94+
return c.conn.SetReadDeadline(t)
95+
}
96+
97+
func (c *Conn) SetWriteDeadline(t time.Time) error {
98+
return c.conn.SetWriteDeadline(t)
99+
}

pgconn/errors.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,3 +254,20 @@ func (e *NotPreferredError) SafeToRetry() bool {
254254
func (e *NotPreferredError) Unwrap() error {
255255
return e.err
256256
}
257+
258+
type PrepareError struct {
259+
err error
260+
261+
ParseComplete bool // Indicates whether the error occurred after a ParseComplete message was received.
262+
}
263+
264+
func (e *PrepareError) Error() string {
265+
if e.ParseComplete {
266+
return fmt.Sprintf("prepare failed after ParseComplete: %s", e.err.Error())
267+
}
268+
return e.err.Error()
269+
}
270+
271+
func (e *PrepareError) Unwrap() error {
272+
return e.err
273+
}

pgconn/pgconn.go

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -866,6 +866,10 @@ type StatementDescription struct {
866866
//
867867
// Prepare does not send a PREPARE statement to the server. It uses the PostgreSQL Parse and Describe protocol messages
868868
// directly.
869+
//
870+
// In extremely rare cases, Prepare may fail after the Parse is successful, but before the Describe is complete. In this
871+
// case, the returned error will be an error where errors.As with a *PrepareError succeeds and the *PrepareError has
872+
// ParseComplete set to true.
869873
func (pgConn *PgConn) Prepare(ctx context.Context, name, sql string, paramOIDs []uint32) (*StatementDescription, error) {
870874
if err := pgConn.lock(); err != nil {
871875
return nil, err
@@ -893,7 +897,8 @@ func (pgConn *PgConn) Prepare(ctx context.Context, name, sql string, paramOIDs [
893897

894898
psd := &StatementDescription{Name: name, SQL: sql}
895899

896-
var parseErr error
900+
var ParseComplete bool
901+
var pgErr *PgError
897902

898903
readloop:
899904
for {
@@ -904,20 +909,22 @@ readloop:
904909
}
905910

906911
switch msg := msg.(type) {
912+
case *pgproto3.ParseComplete:
913+
ParseComplete = true
907914
case *pgproto3.ParameterDescription:
908915
psd.ParamOIDs = make([]uint32, len(msg.ParameterOIDs))
909916
copy(psd.ParamOIDs, msg.ParameterOIDs)
910917
case *pgproto3.RowDescription:
911918
psd.Fields = pgConn.convertRowDescription(nil, msg)
912919
case *pgproto3.ErrorResponse:
913-
parseErr = ErrorResponseToPgError(msg)
920+
pgErr = ErrorResponseToPgError(msg)
914921
case *pgproto3.ReadyForQuery:
915922
break readloop
916923
}
917924
}
918925

919-
if parseErr != nil {
920-
return nil, parseErr
926+
if pgErr != nil {
927+
return nil, &PrepareError{err: pgErr, ParseComplete: ParseComplete}
921928
}
922929
return psd, nil
923930
}

0 commit comments

Comments
 (0)