Skip to content

Commit 96a073a

Browse files
Morlejclaude
andauthored
feat(dsql): enhance query plan explainability with type coercion detection, rewrites, and workflow extraction (awslabs#162)
* feat(dsql): enhance query plan explainability with type coercion detection and rewrite references - Add structured trigger phrases and routing criteria for query plan diagnosis - Add type coercion index bypass detection (implicit cast compatibility matrix) - Extend catalog queries with indexed column type retrieval - Add generic SQL rewrite reference (11 patterns: OR-to-IN, subquery unnesting, etc.) - Add DSQL-specific rewrite reference (reltuples estimate, split large joins for DP threshold) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(dsql): extract query plan workflow, add rewrite evals - Extract Workflow 8 (query plan explainability) from SKILL.md into references/query-plan/workflow.md to stay under the 300 LOC limit - Wire query-rewrites-generic.md and query-rewrites-dsql-specific.md into the workflow (Phase 0 load list + Phase 2 evidence gathering) - Add behavioral evals (query_plan_rewrite_evals.json) covering type coercion detection, subquery unnesting, OR-to-IN, GROUP BY pushdown, large join splitting, and reltuples estimation - Add eval results (query_plan_rewrite_eval_results.md) with with-skill vs baseline comparison Validation: - validate-size.py: 275 lines (good) - validate-references.py: 0 broken links, 0 new orphans Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: correct markdown link fragments in workflow.md TOC Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * style: apply dprint table formatting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * refactor(dsql): address PR review — split rewrites, positive language, RFC keywords Review feedback from amaksimo: - Split query-rewrites-generic.md into 11 individual files under query-rewrites/ subdirectory to reduce context consumption - Split query-rewrites-dsql-specific.md into individual files - Convert monolithic files to index tables pointing to sub-files - Fix DATEADD() SQL Server syntax → PostgreSQL NOW() - INTERVAL - Flip negative language ("Do not apply") to positive ("Skip when") - Add RFC keywords (MUST, SHOULD, MAY) throughout - Remove psql fallback from workflow.md (enforce MCP usage) - Update plan-interpretation.md recommendation template with RFC language - Make Phase 0 loading explicit: MUST for core refs, SHOULD for rewrites Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(dsql): address PR awslabs#162 review — correctness, references, evals Correctness fixes (review items 1-5): - #1: push-computation-to-constant — use NUMERIC column 'amount' to avoid integer division non-equivalence - #2: not-in-to-not-exists — add NULL semantics warning (NOT EXISTS does not preserve NOT IN's NULL-propagation; MUST confirm with user) - awslabs#3/awslabs#4: subquery-unnesting — prefer EXISTS form (true semi-join); document uniqueness precondition for JOIN+DISTINCT alternative - awslabs#5: subquery-unnesting-scalar — add COALESCE(s_count, 0) for COUNT/SUM (LEFT JOIN returns NULL, scalar returns 0) Dangling reference fixes (review items 6-8): - awslabs#6: workflow.md trigger table — "Phase 5" → reassessment re-entry - awslabs#7: Replace all "implicit cast compatibility matrix" references with "pg_amop query in catalog-queries.md" - awslabs#8: plan-interpretation.md L202 — fix cast-vs-operator contradiction Structural fixes (review items 9-14, 24): - awslabs#9: Hedge "integer family" claim with "at time of writing" + verify - awslabs#10: amopmethod=10003 — add provenance comment and verification SQL - awslabs#11: catalog-queries.md TOC — add 3 missing sections - awslabs#12: plan-interpretation.md TOC — add Type Coercion section - awslabs#13: SKILL.md — explicitly delegate routing to workflow.md - awslabs#24: workflow.md — remove em dashes from headings for clean anchors Other fixes (review items 21-23): - awslabs#21: reltuples-estimate — add staleness warning (MUST warn user) - awslabs#22: catalog-queries — add safe_query.build() note for placeholders - awslabs#23: "Skip when" → "SHOULD skip when" in all rewrite files Eval improvements (review items 14, 16): - awslabs#14: README — add query_plan_rewrite_evals to directory tree and eval section - awslabs#16: Add evals 206-210 covering LEFT JOIN, computation push, NOT IN with NULL warning, nested UNION ALL, and negative case (OR across different columns) - awslabs#7 (eval): Update eval 201 expectation — pg_amop instead of matrix Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(dsql): address remaining PR awslabs#162 review items - awslabs#17: Downgrade eval results to qualitative comparison, record model and version, note n=1 and recommend n>=3 for production confidence - awslabs#18: SKILL.md is 281 lines (will update PR body) - awslabs#20: Strengthen awsknowledge fallback to MUST — refuse fallback when recommendation depends on exact limit value - awslabs#21: Already addressed in prior commit (reltuples staleness) - awslabs#15: Document manual-only status and future Python converter direction (per anwesham-lab's suggestion for deterministic rewrites) - awslabs#19: MCP mirror PR noted as follow-up in PR body Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(dsql): address self-review findings — links, inclusive language, evals - Revert broken relative links in SKILL.md (mcp/.mcp.json, scripts/) back to correct ../../ paths - Rename blacklisted_customers → excluded_customers and blacklist → exclusion_list for inclusive language compliance - Fix stale 'implicit cast compatibility matrix' → pg_amop in eval results - Add eval results for 206–210 (LEFT JOIN, computation push, NOT IN NULL warning, nested UNION ALL, negative OR case) - Include hooks.json update * fix: remove stray conflict marker in evals README * style: apply dprint table formatting to eval results * fix(dsql): wire workflow.md and rewrite indexes into SKILL.md Addresses amaksimo's review comment: after rebase onto main, the Workflow 9 section lost its link to workflow.md and the rewrite index files, making all 16 new query-plan files unreachable orphans. - Add workflow.md as the entry gate in the reference table - Add query-rewrites-generic.md and query-rewrites-dsql-specific.md - Update Workflow 9 section to load workflow.md instead of listing the 4 Phase-0 files directly (workflow.md handles that routing) * fix(dsql): address review findings awslabs#4, awslabs#5, awslabs#7, awslabs#8, awslabs#9 - awslabs#4: COALESCE rule in subquery-unnesting-scalar.md restricted to COUNT only; SUM/MAX/MIN return NULL on empty sets in both forms - awslabs#5: verify-comment in catalog-queries.md changed from amname='btree' to amname='btree_index' (DSQL uses btree_index AM) - awslabs#7: workflow.md context disambiguation removes psql fallback offer, now says no MCP means no plan capture (consistent with Safety) - awslabs#8: split-large-joins.md example expanded from 7 to 11 tables, exceeding the stated DP threshold of 10; CTEs project explicit cols - awslabs#9: plan-interpretation.md recommendation template changed ::float to ::integer (only integer-family cross-type operators registered) * fix(dsql): correct safe_query substitution guidance in catalog-queries Lift the substitution rule to the file preamble with per-position guidance: identifier positions (FROM, GROUP BY) use ident(); string- literal positions (WHERE = '{schema}', IN ('{table}')) use allow() or regex(). The prior note incorrectly prescribed ident() for all positions, which would produce invalid SQL in WHERE clauses. * refactor(dsql): merge in-subquery-to-exists into subquery-unnesting-uncorrelated Delete redundant in-subquery-to-exists.md — same input shape and same rewrite as subquery-unnesting-uncorrelated.md. Merge the 'large result set' trigger and 'small static set' skip condition into the canonical file. Update index and eval 200 to route exclusively there. * style: apply dprint table formatting to workflow.md * fix(dsql): address merge-blocking review findings #1-awslabs#4 - #1: Strip surrounding quotes from all placeholders in catalog-queries so safe_query helpers (which emit their own quotes) don't double-quote. Add worked safe_query.build() example to preamble. - #2: DP threshold changed from 10 to 8 (validated: SHOW join_collapse_limit = 8 on live DSQL). Agent now instructed to SHOW the value rather than hardcoding. - awslabs#3: Remove pg_stat_user_tables.last_analyze cross-check (DSQL never populates it). Guard reltuples with GREATEST(..., 0) for the -1 sentinel on never-analyzed tables. - awslabs#4: Fix '11 generic' to '10 generic' in SKILL.md reference table. * feat(dsql): add blog learnings — filter model, DPU, CTE rewrite - Add Three-Layer Filter Model (Index Cond / Storage Filter / Query Processor Filter) with optimization table to plan-interpretation.md - Add Fixing Storage Lookups guidance (INCLUDE columns) with example - Add Cost Number Interpretation (startup ~100 is normal in DSQL) - Add DPU Interpretation (Read DPU as primary signal, optimization loop) - Add CTE late materialization as DSQL-specific rewrite pattern (defer Storage Lookups past LIMIT) - Update workflow.md Phase 1: recommend plain EXPLAIN first for expensive queries before EXPLAIN ANALYZE VERBOSE * chore: bump databases-on-aws plugin version to 1.4.0 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ba79e65 commit 96a073a

26 files changed

Lines changed: 1415 additions & 20 deletions

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@
164164
"name": "databases-on-aws",
165165
"source": "./plugins/databases-on-aws",
166166
"tags": ["aws", "database", "aurora", "dsql", "serverless", "postgresql"],
167-
"version": "1.3.3"
167+
"version": "1.4.0"
168168
},
169169
{
170170
"category": "deployment",

plugins/databases-on-aws/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,5 +22,5 @@
2222
"license": "Apache-2.0",
2323
"name": "databases-on-aws",
2424
"repository": "https://github.com/awslabs/agent-plugins",
25-
"version": "1.3.3"
25+
"version": "1.4.0"
2626
}

plugins/databases-on-aws/.codex-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "databases-on-aws",
3-
"version": "1.3.3",
3+
"version": "1.4.0",
44
"description": "Expert database guidance for the AWS database portfolio. Design schemas, execute queries, handle migrations, and choose the right database for your workload.",
55
"author": {
66
"name": "Amazon Web Services",

plugins/databases-on-aws/hooks/hooks.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"hooks": [
77
{
88
"type": "prompt",
9-
"prompt": "A DSQL transact operation completed. Verify the result: if it was a DDL change, confirm the schema looks correct using get_schema. If it was a DML change, confirm the affected row count is expected."
9+
"prompt": "A DSQL transact operation completed. Verify the result: if it was a DDL change, confirm the schema looks correct using get_schema. If it was a DML change, confirm the affected row count is expected. If it was an EXPLAIN operation, follow up on Query Plan Explainability within the DSQL skill"
1010
}
1111
]
1212
}

plugins/databases-on-aws/skills/dsql/SKILL.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,12 +77,15 @@ Load these files as needed for detailed guidance:
7777

7878
### Query Plan Explainability:
7979

80-
| Reference | When to Load | Contains |
81-
| --------------------------------------------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------- |
82-
| [query-plan/plan-interpretation.md](references/query-plan/plan-interpretation.md) | MUST load at Workflow 9 Phase 0 | DSQL node types, Node Duration math, estimation-error bands |
83-
| [query-plan/catalog-queries.md](references/query-plan/catalog-queries.md) | MUST load at Workflow 9 Phase 0 | `pg_class`/`pg_stats`/`pg_indexes` SQL, correlated-predicate verification |
84-
| [query-plan/guc-experiments.md](references/query-plan/guc-experiments.md) | MUST load at Workflow 9 Phase 0 | GUC experiment procedures, 30-second skip protocol |
85-
| [query-plan/report-format.md](references/query-plan/report-format.md) | MUST load at Workflow 9 Phase 0 | Required report structure, element checklist, support request template |
80+
| Reference | When to Load | Contains |
81+
| --------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------------------------------- |
82+
| [query-plan/workflow.md](references/query-plan/workflow.md) | MUST load at Workflow 9 entry — gates all other files | Trigger criteria, context disambiguation, routing, phased workflow |
83+
| [query-plan/plan-interpretation.md](references/query-plan/plan-interpretation.md) | MUST load at Workflow 9 Phase 0 | DSQL node types, Node Duration math, estimation-error bands |
84+
| [query-plan/catalog-queries.md](references/query-plan/catalog-queries.md) | MUST load at Workflow 9 Phase 0 | `pg_class`/`pg_stats`/`pg_indexes` SQL, correlated-predicate verification |
85+
| [query-plan/guc-experiments.md](references/query-plan/guc-experiments.md) | MUST load at Workflow 9 Phase 0 | GUC experiment procedures, 30-second skip protocol |
86+
| [query-plan/report-format.md](references/query-plan/report-format.md) | MUST load at Workflow 9 Phase 0 | Required report structure, element checklist, support request template |
87+
| [query-plan/query-rewrites-generic.md](references/query-plan/query-rewrites-generic.md) | SHOULD load at Phase 0; sub-files on-demand | Index of 10 generic rewrite patterns |
88+
| [query-plan/query-rewrites-dsql-specific.md](references/query-plan/query-rewrites-dsql-specific.md) | SHOULD load at Phase 0; sub-files on-demand | Index of DSQL-specific rewrite patterns |
8689

8790
---
8891

@@ -220,7 +223,7 @@ MUST load [dsql-lint.md](references/dsql-lint.md) before running `dsql_lint`. Ru
220223

221224
Explains why the DSQL optimizer chose a particular plan. Triggered by slow queries, high DPU, unexpected Full Scans, or plans the user doesn't understand. **REQUIRES a structured Markdown diagnostic report as the deliverable.**
222225

223-
MUST load all four reference files at Phase 0: [query-plan/plan-interpretation.md](references/query-plan/plan-interpretation.md), [query-plan/catalog-queries.md](references/query-plan/catalog-queries.md), [query-plan/guc-experiments.md](references/query-plan/guc-experiments.md), [query-plan/report-format.md](references/query-plan/report-format.md). The phase procedures (capture plan, gather evidence, experiment, produce report) are defined in those files.
226+
MUST load [query-plan/workflow.md](references/query-plan/workflow.md) at entry — it defines trigger criteria, context disambiguation, routing, and the full phased workflow (Phase 0–4). Workflow.md specifies which reference files to load at each phase.
224227

225228
**Safety.** Plan capture uses `readonly_query` exclusively. Rewrite DML to SELECT for plan capture. **MUST NOT** use `transact --allow-writes` for plan capture.
226229

plugins/databases-on-aws/skills/dsql/references/query-plan/catalog-queries.md

Lines changed: 107 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,34 @@
22

33
Exact SQL for interrogating optimizer statistics and actual cardinalities against the DSQL cluster.
44

5+
**Placeholder substitution:** All queries in this file use `{...}` placeholders. MUST substitute via `safe_query.build()` — see input-validation.md. Use the correct helper per position:
6+
7+
- **Identifier positions** (FROM clause, GROUP BY, column aliases): `ident()` → emits `"value"`
8+
- **String-literal positions** (WHERE `= {schema}`, `IN ({table})`, equality comparisons against catalog columns): `allow()` or `regex()` → emits `'value'`
9+
10+
Worked example:
11+
12+
```python
13+
safe_query.build(
14+
"SELECT reltuples FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace "
15+
"WHERE n.nspname = {schema} AND c.relname IN ({t1}, {t2})",
16+
schema=regex(r"^[a-z_]+$", user_schema),
17+
t1=regex(r"^[a-z_]+$", table1),
18+
t2=regex(r"^[a-z_]+$", table2),
19+
)
20+
```
21+
522
## Table of Contents
623

724
1. [Table-Level Statistics (pg_class)](#table-level-statistics)
825
2. [Column Statistics (pg_stats)](#column-statistics)
926
3. [Index Definitions](#index-definitions)
1027
4. [Actual Row Counts](#actual-row-counts)
1128
5. [Actual Distinct Counts](#actual-distinct-counts)
12-
6. [Value Distribution Analysis](#value-distribution-analysis)
29+
6. [Column Types for Predicate Columns](#column-types-for-predicate-columns)
30+
7. [B-Tree Cross-Type Operator Support](#b-tree-cross-type-operator-support)
31+
8. [Indexed Column Types](#indexed-column-types)
32+
9. [Value Distribution Analysis](#value-distribution-analysis)
1333

1434
---
1535

@@ -25,8 +45,8 @@ SELECT
2545
relpages
2646
FROM pg_class c
2747
JOIN pg_namespace n ON n.oid = c.relnamespace
28-
WHERE n.nspname = '{schema}'
29-
AND c.relname IN ('{table1}', '{table2}', '{table3}');
48+
WHERE n.nspname = {schema}
49+
AND c.relname IN ({table1}, {table2}, {table3});
3050
```
3151

3252
Compare `reltuples` against actual `COUNT(*)`. A divergence >20% on the table-stats snapshot indicates stale `reltuples` requiring `ANALYZE`. This is distinct from the row-estimate-vs-actual error thresholds used for plan findings (see plan-interpretation.md: 2x–5x minor, 5x–50x significant, 50x+ severe).
@@ -46,9 +66,9 @@ SELECT
4666
histogram_bounds,
4767
correlation
4868
FROM pg_stats
49-
WHERE schemaname = '{schema}'
50-
AND tablename = '{table}'
51-
AND attname IN ('{col1}', '{col2}');
69+
WHERE schemaname = {schema}
70+
AND tablename = {table}
71+
AND attname IN ({col1}, {col2});
5272
```
5373

5474
**Key fields:**
@@ -71,8 +91,8 @@ SELECT
7191
indexname,
7292
indexdef
7393
FROM pg_indexes
74-
WHERE schemaname = '{schema}'
75-
AND tablename IN ('{table1}', '{table2}', '{table3}')
94+
WHERE schemaname = {schema}
95+
AND tablename IN ({table1}, {table2}, {table3})
7696
ORDER BY tablename, indexname;
7797
```
7898

@@ -103,6 +123,85 @@ Compare against `pg_stats.n_distinct`:
103123
- If `n_distinct` is positive: compare directly
104124
- If `n_distinct` is negative: multiply absolute value by actual row count to get estimated distinct count
105125

126+
## Column Types for Predicate Columns
127+
128+
Retrieve the declared types for columns used in WHERE predicates and JOIN conditions, to detect type coercion index bypass (see plan-interpretation.md):
129+
130+
```sql
131+
SELECT
132+
c.table_name,
133+
c.column_name,
134+
c.data_type,
135+
c.udt_name,
136+
c.is_nullable
137+
FROM information_schema.columns c
138+
WHERE c.table_schema = {schema}
139+
AND c.table_name IN ({table1}, {table2})
140+
AND c.column_name IN ({col1}, {col2});
141+
```
142+
143+
Cross-reference the column type against predicate literals visible in the EXPLAIN output. When the types differ, use the B-Tree Cross-Type Operator Support query below to determine whether the mismatch prevents index usage.
144+
145+
## B-Tree Cross-Type Operator Support
146+
147+
Determine which type pairs the DSQL B-Tree access method supports for index scans. If a (predicate-type, column-type) pair has no registered operator, the index cannot be used for that comparison:
148+
149+
```sql
150+
SELECT DISTINCT
151+
lt.typname AS left_type,
152+
rt.typname AS right_type
153+
FROM pg_amop ao
154+
JOIN pg_type lt ON lt.oid = ao.amoplefttype
155+
JOIN pg_type rt ON rt.oid = ao.amoprighttype
156+
-- 10003 is DSQL's B-Tree OID (PG mainline is 403).
157+
-- Verify with: SELECT oid FROM pg_am WHERE amname = 'btree_index'
158+
WHERE ao.amopmethod = 10003
159+
AND ao.amoplefttype != ao.amoprighttype
160+
ORDER BY lt.typname, rt.typname;
161+
```
162+
163+
This returns only the cross-type pairs (where left and right types differ). Same-type pairs are always supported. Use this to confirm whether a suspected type mismatch actually prevents index usage — if the pair appears in the result, the index CAN be used and the issue lies elsewhere.
164+
165+
To check a specific pair:
166+
167+
```sql
168+
SELECT EXISTS (
169+
SELECT 1
170+
FROM pg_amop ao
171+
JOIN pg_type lt ON lt.oid = ao.amoplefttype
172+
JOIN pg_type rt ON rt.oid = ao.amoprighttype
173+
-- 10003 = DSQL B-Tree OID; verify with: SELECT oid FROM pg_am WHERE amname = 'btree_index'
174+
WHERE ao.amopmethod = 10003
175+
AND lt.typname = {predicate_type}
176+
AND rt.typname = {column_type}
177+
) AS index_usable;
178+
```
179+
180+
## Indexed Column Types
181+
182+
Retrieve index definitions together with their column types to identify type coercion bypass candidates:
183+
184+
```sql
185+
SELECT
186+
i.indexname,
187+
i.tablename,
188+
a.attname AS column_name,
189+
t.typname AS column_type,
190+
i.indexdef
191+
FROM pg_indexes i
192+
JOIN pg_class ic ON ic.relname = i.indexname
193+
JOIN pg_index ix ON ix.indexrelid = ic.oid
194+
JOIN pg_attribute a ON a.attrelid = ix.indrelid
195+
AND a.attnum = ANY(ix.indkey)
196+
JOIN pg_type t ON t.oid = a.atttypid
197+
JOIN pg_namespace n ON n.oid = ic.relnamespace
198+
WHERE n.nspname = {schema}
199+
AND i.tablename IN ({table1}, {table2})
200+
ORDER BY i.tablename, i.indexname, a.attnum;
201+
```
202+
203+
Use this when a Full Scan appears despite an apparently usable index — compare the index column's `column_type` against the predicate literal's inferred type.
204+
106205
## Value Distribution Analysis
107206

108207
For columns with suspected data skew, retrieve the actual top-N value frequencies:

0 commit comments

Comments
 (0)