Skip to content
Merged
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
10 changes: 8 additions & 2 deletions backend/internal/mcpserver/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -670,9 +670,15 @@ func (d *DashaClient) SchemaLintSummary(ctx context.Context, cluster, instance s

// QueryReport returns the full pg_stat_statements report for an instance,
// optionally excluding the given usernames (e.g. monitoring/replication roles).
func (d *DashaClient) QueryReport(ctx context.Context, cluster, instance, database string, excludeUsers []string) (any, error) {
// A named queryID joins the response even when it tops no metric.
func (d *DashaClient) QueryReport(
ctx context.Context,
cluster, instance, database, queryID string,
excludeUsers []string,
) (any, error) {
r, err := d.api.GetQueriesReportWithResponse(ctx, &apiclient.GetQueriesReportParams{
ClusterName: cluster, Instance: instance, Database: opt(database), ExcludeUsers: optStrings(excludeUsers),
ClusterName: cluster, Instance: instance, Database: opt(database),
Queryid: opt(queryID), ExcludeUsers: optStrings(excludeUsers),
}, d.editor(ctx))
if err != nil {
return nil, wrapErr("query_report", err)
Expand Down
9 changes: 5 additions & 4 deletions backend/internal/mcpserver/kb/en/index-advisor.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,11 +183,12 @@ statistics. Recheck it after `ANALYZE`.
`CREATE`. Only `verdict='drop_candidate'` justifies a `DROP`.
- `describe_table` on the candidate's table — how many indexes it already
carries, its size, the HOT share a new index would reduce.
- `query_report` on a host from `query_id_by_host` — the statement's own numbers
on the host that recognizes that queryid. A statement carries a different
- `query_report` on a host from `query_id_by_host`, passing that queryid — the
statement's own numbers and its whole text. A statement carries a different
queryid on each host, so the keyed map is the only safe source for a
drill-down.
drill-down; the queryid also pulls the row in when it tops no metric.
- `index_advisor` with `include_queries=true` — the normalized statement text,
clipped, when the fingerprint is not enough.
clipped at 1000 bytes, when the fingerprint is enough to identify it but not
to read it.
- `hot_tables` / `hot_indexes` — what the table's real activity looks like
before adding write cost to it.
11 changes: 6 additions & 5 deletions backend/internal/mcpserver/kb/ru/index-advisor.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,10 +182,11 @@ btree-класса операторов; этот шаг предлагает т
оправдан только при `verdict='drop_candidate'`.
- `describe_table` по таблице кандидата — сколько индексов на ней уже есть, её
размер, доля HOT, которую новый индекс уменьшит.
- `query_report` на хосте из `query_id_by_host` — собственные числа запроса на
том хосте, который узнаёт этот queryid. На каждом хосте у запроса свой
queryid, поэтому для drill-down безопасен только этот словарь.
- `index_advisor` с `include_queries=true` — нормализованный текст запроса
(обрезанный), когда fingerprint'а не хватает.
- `query_report` на хосте из `query_id_by_host` с этим же queryid — собственные
числа запроса и его полный текст. На каждом хосте у запроса свой queryid,
поэтому для drill-down безопасен только этот словарь; queryid ещё и добавляет
строку в отчёт, когда запрос не лидирует ни по одной метрике.
- `index_advisor` с `include_queries=true` — нормализованный текст запроса,
обрезанный на 1000 байт, когда fingerprint'а не хватает.
- `hot_tables` / `hot_indexes` — как выглядит реальная активность таблицы, к
которой добавляется стоимость записи.
13 changes: 10 additions & 3 deletions backend/internal/mcpserver/tools.go
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,7 @@ type queryReportArgs struct {
Cluster string `json:"cluster" jsonschema:"Dasha cluster name"`
Instance string `json:"instance" jsonschema:"Dasha instance / host name"`
Database string `json:"database,omitempty" jsonschema:"Optional: database name; omit for the whole instance"`
Queryid string `json:"queryid,omitempty" jsonschema:"Optional: pg_stat_statements queryid to include whatever it ranks; the report otherwise holds only the top of each metric"`
ExcludeUsers []string `json:"exclude_users,omitempty" jsonschema:"Optional: usernames to exclude (e.g. monitoring/replication roles)"`
}

Expand Down Expand Up @@ -356,7 +357,9 @@ func registerTools(s *mcp.Server, c *DashaClient) {
Description: "List the top queries for a cluster/instance, ranked by total execution time " +
"(by='time', default) or WAL volume (by='wal'). Pass database to rank inside one database; " +
"without it the ranking covers every database of the host and each entry names its own " +
"('datname'). Requires pg_stat_statements.",
"('datname'). 'QueryTrunc' holds the first 48 characters of the statement (64 for by='wal'), " +
"never the whole one: to read or quote the statement call query_report with the row's " +
"'QueryID' and 'Datname'. Requires pg_stat_statements.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, a topQueriesArgs) (*mcp.CallToolResult, any, error) {
switch a.By {
case "", "time":
Expand Down Expand Up @@ -605,9 +608,13 @@ func registerTools(s *mcp.Server, c *DashaClient) {
"rows, I/O). Every row names its database in 'datname' and is ranked within it, so a queryid " +
"identifies a statement only together with that name. Pass database to keep only that one — " +
"percentages are then shares of it, otherwise shares of the whole instance. Pass exclude_users " +
"to drop noise from monitoring/replication roles. Requires pg_stat_statements.",
"to drop noise from monitoring/replication roles. This is the source of statement text: 'Query' " +
"is the whole statement as pg_stat_statements stored it, clipped only by the server's " +
"track_activity_query_size (a text cut there ends in '...'), unlike the 48-character 'QueryTrunc' " +
"of top_queries. Rows are the top of each metric per database, so pass queryid to pull in a " +
"statement that leads none of them. Requires pg_stat_statements.",
}, func(ctx context.Context, _ *mcp.CallToolRequest, a queryReportArgs) (*mcp.CallToolResult, any, error) {
return jsonResult(c.QueryReport(ctx, a.Cluster, a.Instance, a.Database, a.ExcludeUsers))
return jsonResult(c.QueryReport(ctx, a.Cluster, a.Instance, a.Database, a.Queryid, a.ExcludeUsers))
})

addTool(s, &mcp.Tool{
Expand Down
44 changes: 44 additions & 0 deletions backend/internal/sqlparse/paramcast.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package sqlparse

import (
"regexp"
"strings"
)

// The grammar lets a type name precede a string constant only — `timestamptz
// '2024-01-01'` — and pg_stat_statements normalizes the constant while keeping
// the type name, leaving `timestamptz $1`, which nothing will parse.
//
// The type names are a closed list on purpose: `LIMIT $1`, `LIKE $1` and
// `AT TIME ZONE $1` have the same shape and must survive untouched.
var (
intervalParam = regexp.MustCompile(`(?i)\binterval(\s*\(\s*\d+\s*\))?\s+(\$\d+)` +
`((?:\s+(?:year|month|day|hour|minute|second)\b(?:\s*\(\s*\d+\s*\))?` +
`(?:\s+to\s+(?:month|hour|minute|second)\b(?:\s*\(\s*\d+\s*\))?)?)?)`)

typedParam = regexp.MustCompile(`(?i)\b((?:[a-z_][a-z0-9_$]*\s*\.\s*)?\b(?:` +
`timestamptz|timetz|` +
`timestamp(?:\s*\(\s*\d+\s*\))?(?:\s+with(?:out)?\s+time\s+zone)?|` +
`time(?:\s*\(\s*\d+\s*\))?(?:\s+with(?:out)?\s+time\s+zone)?|` +
`double\s+precision|national\s+char(?:acter)?(?:\s+varying)?|` +
`char(?:acter)?(?:\s+varying)?|bit(?:\s+varying)?|varbit|varchar|bpchar|nchar|` +
`numeric|decimal|dec|float8|float4|float|real|integer|int8|int4|int2|int|` +
`smallint|bigint|boolean|bool|date|text|uuid|jsonb|json|bytea|inet|cidr|` +
`macaddr8|macaddr|money|xml|name|oid|regclass|tsvector|tsquery|citext|hstore` +
`)(?:\s*\(\s*\d+\s*(?:,\s*\d+\s*)?\))?)\s+(\$\d+)\b`)
)

// RestoreParamCasts rewrites `timestamptz $1` into `$1::timestamptz` and
// `interval $1 day` into `CAST($1 AS interval day)`. The cast lands on the
// parameter, never on a column, so the plan the text describes is unchanged.
// An unlisted type name is left alone: a missed rewrite parses exactly as well
// as it does now.
func RestoreParamCasts(sql string) string {
if !strings.Contains(sql, "$") {
return sql
}

out := intervalParam.ReplaceAllString(sql, `CAST(${2} AS interval${1}${3})`)

return typedParam.ReplaceAllString(out, `${2}::${1}`)
}
186 changes: 186 additions & 0 deletions backend/internal/sqlparse/paramcast_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
package sqlparse

import "testing"

// Every sql here is what pg_stat_statements stores for a statement written with
// a `type 'literal'` constant, and every want is valid SQL — TestParseAcceptsTypePrefixedParams
// runs the same corpus through the grammar.
var typePrefixedCorpus = []struct {
name string
sql string
want string
}{
{
name: "timestamptz",
sql: `SELECT count(*) FROM orders_2024 WHERE created_at > timestamptz $1`,
want: `SELECT count(*) FROM orders_2024 WHERE created_at > $1::timestamptz`,
},
{
name: "spelled out with time zone",
sql: `SELECT * FROM orders WHERE created_at > timestamp with time zone $1`,
want: `SELECT * FROM orders WHERE created_at > $1::timestamp with time zone`,
},
{
name: "precision and without time zone",
sql: `SELECT * FROM orders WHERE created_at > timestamp(3) without time zone $1`,
want: `SELECT * FROM orders WHERE created_at > $1::timestamp(3) without time zone`,
},
{
name: "numeric typmod",
sql: `SELECT * FROM orders WHERE amount > numeric(10,2) $1`,
want: `SELECT * FROM orders WHERE amount > $1::numeric(10,2)`,
},
{
name: "character varying",
sql: `SELECT * FROM users WHERE name = character varying(10) $1`,
want: `SELECT * FROM users WHERE name = $1::character varying(10)`,
},
{
name: "date",
sql: `SELECT * FROM events WHERE d = date $1`,
want: `SELECT * FROM events WHERE d = $1::date`,
},
{
name: "boolean",
sql: `SELECT * FROM events WHERE flag = boolean $1`,
want: `SELECT * FROM events WHERE flag = $1::boolean`,
},
{
name: "schema qualified type",
sql: `SELECT * FROM events WHERE d = pg_catalog.date $1`,
want: `SELECT * FROM events WHERE d = $1::pg_catalog.date`,
},
{
name: "case is irrelevant",
sql: `SELECT * FROM orders WHERE created_at > TIMESTAMPTZ $1`,
want: `SELECT * FROM orders WHERE created_at > $1::TIMESTAMPTZ`,
},
{
name: "both sides of between",
sql: `SELECT * FROM orders WHERE created_at BETWEEN timestamptz $1 AND timestamptz $2`,
want: `SELECT * FROM orders WHERE created_at BETWEEN $1::timestamptz AND $2::timestamptz`,
},
{
name: "interval",
sql: `SELECT * FROM events WHERE ts > now() - interval $1`,
want: `SELECT * FROM events WHERE ts > now() - CAST($1 AS interval)`,
},
{
name: "interval keeps its qualifier",
sql: `SELECT * FROM events WHERE ts > now() - interval $1 day to second`,
want: `SELECT * FROM events WHERE ts > now() - CAST($1 AS interval day to second)`,
},
{
name: "interval precision",
sql: `SELECT * FROM events WHERE ts > now() - interval (3) $1`,
want: `SELECT * FROM events WHERE ts > now() - CAST($1 AS interval (3))`,
},
{
name: "an alias is not an interval qualifier",
sql: `SELECT interval $1 day_offset FROM events`,
want: `SELECT CAST($1 AS interval) day_offset FROM events`,
},
}

func TestRestoreParamCastsRewritesTypePrefixedParams(t *testing.T) {
for _, tc := range typePrefixedCorpus {
t.Run(tc.name, func(t *testing.T) {
if got := RestoreParamCasts(tc.sql); got != tc.want {
t.Errorf("got %s\nwant %s", got, tc.want)
}
})
}
}

// A keyword in front of a parameter has the same shape as a type name and is the
// one thing this rewrite must never touch.
func TestRestoreParamCastsLeavesValidSqlAlone(t *testing.T) {
cases := []struct {
name string
sql string
}{
{"limit and offset", `SELECT * FROM orders ORDER BY id LIMIT $1 OFFSET $2`},
{"like and escape", `SELECT * FROM users WHERE email LIKE $1 ESCAPE $2`},
{"between", `SELECT * FROM orders WHERE created_at BETWEEN $1 AND $2`},
{"at time zone", `SELECT created_at AT TIME ZONE $1 FROM orders`},
{"fetch first", `SELECT * FROM orders ORDER BY id FETCH FIRST $1 ROWS ONLY`},
{"already a cast", `SELECT * FROM orders WHERE created_at > $1::timestamptz`},
{"no type prefix", `SELECT * FROM orders WHERE created_at > $1`},
{"type name is only a suffix", `SELECT * FROM t WHERE x = mytimestamptz $1`},
{"no parameters at all", `SELECT * FROM orders WHERE created_at > timestamptz '2024-01-01'`},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := RestoreParamCasts(tc.sql); got != tc.sql {
t.Errorf("rewritten to %s", got)
}
})
}
}

func TestRestoreParamCastsIsIdempotent(t *testing.T) {
for _, tc := range typePrefixedCorpus {
t.Run(tc.name, func(t *testing.T) {
if got := RestoreParamCasts(tc.want); got != tc.want {
t.Errorf("second pass changed it to %s", got)
}
})
}
}

// The grammar is the only judge of whether the rewrite produced SQL, and Parse
// is what has to accept the text pg_stat_statements actually stores.
func TestParseAcceptsTypePrefixedParams(t *testing.T) {
p := New(Config{})

for _, tc := range typePrefixedCorpus {
t.Run(tc.name, func(t *testing.T) {
if _, err := p.Parse(tc.sql); err != nil {
t.Fatalf("parse: %v", err)
}

if _, err := p.Fingerprint(tc.sql); err != nil {
t.Fatalf("fingerprint: %v", err)
}
})
}
}

// The cast lands on the parameter, so the predicate stays a plain range on the
// column and still yields an index candidate.
func TestParseKeepsColumnUsageAfterRestore(t *testing.T) {
p := New(Config{})

cases := []struct {
name string
sql string
usages []string
}{
{
name: "type prefixed bound",
sql: `SELECT count(*) FROM orders_2024 WHERE created_at > timestamptz $1`,
usages: []string{"orders_2024.created_at range"},
},
{
name: "interval arithmetic on the bound",
sql: `SELECT * FROM events WHERE tenant_id = $1 AND ts > now() - interval $2`,
usages: []string{"events.tenant_id equality", "events.ts range"},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
st, err := p.Parse(tc.sql)
if err != nil {
t.Fatalf("parse: %v", err)
}

assertSet(t, "usages", usageStrings(st.Usages), tc.usages)

if len(st.Unsupported) != 0 {
t.Errorf("unsupported = %v, want none", st.Unsupported)
}
})
}
}
19 changes: 17 additions & 2 deletions backend/internal/sqlparse/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,12 +126,21 @@ func (p *parser) parseUncached(sql string) (Statement, error) {
return Statement{}, err
}

tree, err := p.parseTree(sql)
text := sql

tree, err := p.parseTree(text)
if err != nil {
if fixed := RestoreParamCasts(sql); fixed != sql {
text = fixed
tree, err = p.parseTree(text)
}
}

if err != nil {
return Statement{}, reason(ReasonParseError, err)
}

fp, err := p.fingerprint(sql)
fp, err := p.fingerprint(text)
if err != nil {
return Statement{}, reason(ReasonParseError, err)
}
Expand Down Expand Up @@ -167,6 +176,12 @@ func (p *parser) Fingerprint(sql string) (string, error) {
}

fp, err := p.fingerprint(sql)
if err != nil {
if fixed := RestoreParamCasts(sql); fixed != sql {
fp, err = p.fingerprint(fixed)
}
}

if err != nil {
return "", reason(ReasonParseError, err)
}
Expand Down