Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .chloggen/fix-postgresql-explain-context.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Use this changelog template to create an entry for release notes.

# One of 'breaking', 'deprecation', 'new_component', 'enhancement', 'bug_fix'
change_type: 'bug_fix'

# The name of the component, or a single word describing the area of concern, (e.g. receiver/filelog)
component: 'receiver/postgresqlreceiver'

# A brief description of the change. Surround your text with quotes ("") if it needs to start with a backtick (`).
note: 'Fixes a bug in `explainQuery` so that it honors context cancellation'

# Mandatory: One or more tracking issues related to the change. You can use the PR number here if no issue exists.
issues: []

# (Optional) One or more lines of additional information to render under the primary note.
# These lines will be padded with 2 spaces and then inserted directly into the document.
# Use pipe (|) for multiline entries.
subtext:

# If your change doesn't affect end users or the exported elements of any package,
# you should instead start your pull request title with [chore] or use the "Skip Changelog" label.
# Optional: The change log or logs in which this entry should be included.
# e.g. '[user]' or '[user, api]'
# Include 'user' if the change is relevant to end users.
# Include 'api' if there is a change to a library API.
# Default: '[user]'
change_logs: []
18 changes: 14 additions & 4 deletions receiver/postgresqlreceiver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ import (

const querySampleTraceContextKey = "_otel_trace_context"

// detachedCleanupTimeout bounds the best-effort DEALLOCATE PREPARE cleanup in
// explainQuery so a truly unresponsive backend can't hang cleanup forever.
const detachedCleanupTimeout = 5 * time.Second

// databaseName is a name that refers to a database so that it can be uniquely referred to later
// i.e. database1
type databaseName string
Expand Down Expand Up @@ -68,7 +72,7 @@ type client interface {
getVersion(ctx context.Context) (string, error)
getQuerySamples(ctx context.Context, limit int64, newestQueryTimestamp float64, logger *zap.Logger) ([]map[string]any, float64, error)
getTopQuery(ctx context.Context, limit int64, logger *zap.Logger) ([]map[string]any, error)
explainQuery(query, queryID string, logger *zap.Logger) (string, error)
explainQuery(ctx context.Context, query, queryID string, logger *zap.Logger) (string, error)
}

type postgreSQLClient struct {
Expand Down Expand Up @@ -129,7 +133,7 @@ func isExplainableQuery(query string) bool {
}

// explainQuery implements client.
func (c *postgreSQLClient) explainQuery(query, queryID string, logger *zap.Logger) (string, error) {
func (c *postgreSQLClient) explainQuery(ctx context.Context, query, queryID string, logger *zap.Logger) (string, error) {
// Check if the query is explainable before attempting EXPLAIN
if !isExplainableQuery(query) {
logger.Debug("skipping EXPLAIN for non-explainable query", zap.String("queryID", queryID))
Expand All @@ -148,8 +152,14 @@ func (c *postgreSQLClient) explainQuery(query, queryID string, logger *zap.Logge
nulls[i] = "null"
}

// run cleanup on a context that survives cancellation of ctx, so it still
// runs even if ctx was already canceled or timed out.
// otherwise, long-lived pooled connections can be left with leaked
// server-side state since prepared statements need to be deallocated
defer func() {
_, _ = c.client.Exec(fmt.Sprintf("/* otel-collector-ignore */ DEALLOCATE PREPARE otel_%s", normalizedQueryID))
cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), detachedCleanupTimeout)
defer cancel()
_, _ = c.client.ExecContext(cleanupCtx, fmt.Sprintf("/* otel-collector-ignore */ DEALLOCATE PREPARE otel_%s", normalizedQueryID))
}()

// if there is no parameter needed, we can not put an empty bracket
Expand All @@ -164,7 +174,7 @@ func (c *postgreSQLClient) explainQuery(query, queryID string, logger *zap.Logge

wrappedDb := sqlquery.NewDbClient(sqlquery.DbWrapper{Db: c.client}, setPlanCacheMode+prepareStatement+explainStatement, logger, sqlquery.TelemetryConfig{})

result, err := wrappedDb.QueryRows(context.Background())
result, err := wrappedDb.QueryRows(ctx)
if err != nil {
logger.Error("failed to explain statement", zap.Error(err))
return "", err
Expand Down
2 changes: 1 addition & 1 deletion receiver/postgresqlreceiver/scraper.go
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ func (p *postgreSQLScraper) collectTopQuery(ctx context.Context, clientFactory p
database := item.Value[string(semconv.DBNamespaceKey)].(string)
dbClient, err := clientFactory.getClient(database)
if err == nil {
plan, err = dbClient.explainQuery(rawQuery, queryID, logger)
plan, err = dbClient.explainQuery(ctx, rawQuery, queryID, logger)
if err != nil {
logger.Error("failed to explain query", zap.String("query", rawQuery), zap.Error(err))
}
Expand Down
64 changes: 62 additions & 2 deletions receiver/postgresqlreceiver/scraper_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -968,13 +968,73 @@ func TestExplainQuery(t *testing.T) {
sqlmock.NewRows([]string{"QUERY PLAN"}).AddRow(tc.mockPlanResult),
)

plan, err := client.explainQuery(tc.query, tc.queryID, logger)
// The prepared statement must always be deallocated afterwards,
// otherwise it leaks server-side state on pooled connections.
normalizedQueryID := strings.ReplaceAll(tc.queryID, "-", "_")
mock.ExpectExec(fmt.Sprintf("/* otel-collector-ignore */ DEALLOCATE PREPARE otel_%s", normalizedQueryID)).
WillReturnResult(sqlmock.NewResult(0, 0))

plan, err := client.explainQuery(t.Context(), tc.query, tc.queryID, logger)
require.NoError(t, err)
assert.Equal(t, tc.mockPlanResult, plan)
require.NoError(t, mock.ExpectationsWereMet())
})
}
}

func TestExplainQueryErrorStillCleansUp(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
require.NoError(t, err)
defer db.Close()

logger := zap.NewNop()
client := &postgreSQLClient{
client: db,
closeFn: func() error { return nil },
}

expectedSQL := "/* otel-collector-ignore */ SET plan_cache_mode = force_generic_plan;PREPARE otel_12345 AS SELECT * FROM users;EXPLAIN(FORMAT JSON) EXECUTE otel_12345;"
mock.ExpectQuery(expectedSQL).WillReturnError(errors.New("syntax error"))

// Even though the EXPLAIN itself failed, PREPARE may have already
// registered the statement server-side, so cleanup must still run.
mock.ExpectExec("/* otel-collector-ignore */ DEALLOCATE PREPARE otel_12345").
WillReturnResult(sqlmock.NewResult(0, 0))

_, err = client.explainQuery(t.Context(), "SELECT * FROM users", "12345", logger)
require.Error(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}

func TestExplainQueryUsesContext(t *testing.T) {
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherEqual))
require.NoError(t, err)
defer db.Close()

logger := zap.NewNop()
client := &postgreSQLClient{
client: db,
closeFn: func() error { return nil },
}

expectedSQL := "/* otel-collector-ignore */ SET plan_cache_mode = force_generic_plan;PREPARE otel_12345 AS SELECT * FROM users;EXPLAIN(FORMAT JSON) EXECUTE otel_12345;"
mock.ExpectQuery(expectedSQL).
WillDelayFor(200 * time.Millisecond).
WillReturnRows(sqlmock.NewRows([]string{"QUERY PLAN"}).AddRow(`[{"Plan":{"Node Type":"Seq Scan","Relation Name":"users"}}]`))

// Cleanup (DEALLOCATE PREPARE) must still run even though ctx expires
// mid-query, otherwise the prepared statement leaks on pooled connections.
mock.ExpectExec("/* otel-collector-ignore */ DEALLOCATE PREPARE otel_12345").
WillReturnResult(sqlmock.NewResult(0, 0))

ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
defer cancel()

_, err = client.explainQuery(ctx, "SELECT * FROM users", "12345", logger)
require.Error(t, err)
require.NoError(t, mock.ExpectationsWereMet())
}

type (
mockClientFactory struct{ mock.Mock }
mockClient struct{ mock.Mock }
Expand All @@ -984,7 +1044,7 @@ type (
)

// explainQuery implements client.
func (*mockClient) explainQuery(string, string, *zap.Logger) (string, error) {
func (*mockClient) explainQuery(context.Context, string, string, *zap.Logger) (string, error) {
panic("unimplemented")
}

Expand Down
Loading