Commit 3f701cc7 authored by Doug Barrett's avatar Doug Barrett
Browse files

feat(v2/postgres): default to QueryExecModeSimpleProtocol for PgBouncer compatibility

PgBouncer in transaction-pooling mode does not support server-side
prepared statements, which is pgx's default (QueryExecModeCacheStatement).
This causes 'prepared statement already exists' errors in production.

Default to QueryExecModeSimpleProtocol, matching the Container Registry's
approach and GitLab.com's deployment topology. Expose QueryExecMode as a
Config field so consumers connecting directly to PostgreSQL can opt into
cached statements when desired.
parent 826b25a7
Loading
Loading
Loading
Loading
Loading
+22 −3
Original line number Diff line number Diff line
@@ -87,15 +87,24 @@ db, err := postgres.NewWithConfig(&postgres.Config{
    ConnMaxLifetime: 5 * time.Minute,  // recycle connections after this duration
    ConnMaxIdleTime: 60 * time.Second, // close idle connections after this duration

    // Query execution mode (default: pgx.QueryExecModeSimpleProtocol for
    // PgBouncer compatibility). Override only for direct PostgreSQL connections.
    // QueryExecMode: pgx.QueryExecModeCacheStatement,

    Tracer: a.Tracer(), // nil disables query tracing
})
```

### PgBouncer

When running behind PgBouncer in transaction-pooling mode, set
`ConnMaxLifetime` and `ConnMaxIdleTime` to ensure stale connections are
recycled promptly:
The client defaults to `pgx.QueryExecModeSimpleProtocol`, which avoids
server-side prepared statements. This is required for PgBouncer in
transaction-pooling mode (the standard deployment at GitLab.com) and works
correctly with direct PostgreSQL connections too. No additional configuration
is needed for PgBouncer compatibility.

Also set `ConnMaxLifetime` and `ConnMaxIdleTime` to recycle stale
connections:

```go
db, err := postgres.NewWithConfig(&postgres.Config{
@@ -105,6 +114,16 @@ db, err := postgres.NewWithConfig(&postgres.Config{
})
```

When connecting directly to PostgreSQL without a pooler, you can opt into
cached prepared statements for better query performance:

```go
db, err := postgres.NewWithConfig(&postgres.Config{
    DSN:           "postgres://user:pass@localhost:5432/mydb",
    QueryExecMode: pgx.QueryExecModeCacheStatement,
})
```

## Query tracing

When `Tracer` is set, every query automatically receives an OpenTelemetry span:
+30 −0
Original line number Diff line number Diff line
@@ -13,6 +13,12 @@ import (
	"gitlab.com/gitlab-org/labkit/v2/trace"
)

// defaultQueryExecMode is the query execution mode used when
// Config.QueryExecMode is not set. SimpleProtocol avoids server-side prepared
// statements, which is required for PgBouncer in transaction-pooling mode and
// is the standard deployment topology at GitLab.com.
const defaultQueryExecMode = pgx.QueryExecModeSimpleProtocol

// Compile-time check that *Client satisfies app.Component.
var _ app.Component = (*Client)(nil)

@@ -56,6 +62,18 @@ type Config struct {
	// Zero means connections are not closed due to idle time.
	ConnMaxIdleTime time.Duration

	// QueryExecMode controls how pgx executes queries. Defaults to
	// [pgx.QueryExecModeSimpleProtocol], which avoids server-side prepared
	// statements and is required for PgBouncer in transaction-pooling mode
	// (the standard deployment at GitLab.com).
	//
	// Set to [pgx.QueryExecModeCacheStatement] or another mode only when
	// connecting directly to PostgreSQL without a connection pooler.
	//
	// See https://pkg.go.dev/github.com/jackc/pgx/v5#QueryExecMode for all
	// available modes.
	QueryExecMode pgx.QueryExecMode

	// Tracer is used to create spans for queries. When nil, tracing is
	// disabled.
	Tracer *trace.Tracer
@@ -98,6 +116,12 @@ func NewWithConfig(cfg *Config) (*Client, error) {
		return nil, fmt.Errorf("postgres: invalid DSN: %w", err)
	}

	queryExecMode := cfg.QueryExecMode
	if queryExecMode == 0 {
		queryExecMode = defaultQueryExecMode
	}
	connCfg.DefaultQueryExecMode = queryExecMode

	if cfg.Tracer != nil {
		connCfg.Tracer = &queryTracer{tracer: cfg.Tracer, dbName: connCfg.Database}
	}
@@ -137,6 +161,12 @@ func (c *Client) Pool() *pgxpool.Pool {
	return c.pool
}

// QueryExecMode returns the [pgx.QueryExecMode] that will be used for queries.
// The default is [pgx.QueryExecModeSimpleProtocol] for PgBouncer compatibility.
func (c *Client) QueryExecMode() pgx.QueryExecMode {
	return c.connCfg.DefaultQueryExecMode
}

// QueryTracer returns the [pgx.QueryTracer] configured on this client, or nil
// when no [Config.Tracer] was provided. This is primarily useful for testing
// the tracing integration without a live database.
+21 −0
Original line number Diff line number Diff line
@@ -41,6 +41,27 @@ func TestNewWithConfig_WithTracer(t *testing.T) {
	assert.NotNil(t, client)
}

func TestQueryExecMode_DefaultSimpleProtocol(t *testing.T) {
	client, err := postgres.New("postgres://user:pass@localhost:5432/mydb")
	require.NoError(t, err)
	assert.Equal(t, pgx.QueryExecModeSimpleProtocol, client.QueryExecMode())
}

func TestQueryExecMode_NilConfig(t *testing.T) {
	client, err := postgres.NewWithConfig(nil)
	require.NoError(t, err)
	assert.Equal(t, pgx.QueryExecModeSimpleProtocol, client.QueryExecMode())
}

func TestQueryExecMode_CustomOverride(t *testing.T) {
	client, err := postgres.NewWithConfig(&postgres.Config{
		DSN:           "postgres://user:pass@localhost:5432/mydb",
		QueryExecMode: pgx.QueryExecModeCacheStatement,
	})
	require.NoError(t, err)
	assert.Equal(t, pgx.QueryExecModeCacheStatement, client.QueryExecMode())
}

func TestDB_NilBeforeStart(t *testing.T) {
	client, err := postgres.New("postgres://user:pass@localhost:5432/mydb")
	require.NoError(t, err)
+15 −2
Original line number Diff line number Diff line
@@ -46,13 +46,26 @@ db.statement attribute and errors are recorded on the span.

# PgBouncer

When running behind PgBouncer in transaction-pooling mode, set
ConnMaxLifetime and ConnMaxIdleTime to recycle stale connections:
The client defaults to [pgx.QueryExecModeSimpleProtocol], which avoids
server-side prepared statements and is compatible with PgBouncer in
transaction-pooling mode (the standard deployment at GitLab.com). No
additional configuration is required for PgBouncer compatibility.

When running behind PgBouncer, also set ConnMaxLifetime and ConnMaxIdleTime
to recycle stale connections:

	client, err := postgres.NewWithConfig(&postgres.Config{
		DSN:             "postgres://user:pass@pgbouncer:6432/mydb",
		ConnMaxLifetime: 5 * time.Minute,
		ConnMaxIdleTime: 60 * time.Second,
	})

When connecting directly to PostgreSQL without a pooler, you can opt into
cached prepared statements for better performance:

	client, err := postgres.NewWithConfig(&postgres.Config{
		DSN:           "postgres://user:pass@localhost:5432/mydb",
		QueryExecMode: pgx.QueryExecModeCacheStatement,
	})
*/
package postgres