From ebbd8a2082ff849fc6b15a270096bee29e54c063 Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Wed, 30 Sep 2026 14:37:20 -0700 Subject: [PATCH 1/8] docs(dsql): add limits/error-code reference, fix wrong messages troubleshooting.md covered only 40001 and 23503, and five of its quoted error messages did not match what DSQL emits. One matches nothing in the real text, so an agent looking it up finds nothing. Adds a Limits and Error Codes section: 22 limits, 20 rejections and 10 cluster quotas, each with its SQLSTATE and exact message. 54000 covers 15 limit rows and 0A000 covers 13 rejections, so within the section the message is the discriminator, not the code. Four corrections change the advice: - OC001 is 40001, already in the OCC retry loop. OC000 is the DML conflict. OC001 fires when a transaction's snapshot predates a catalog change, which includes GRANT and REVOKE as well as DDL, and whether or not the change is still in flight. A retry takes a fresh snapshot and clears a one-shot change; only a session that keeps changing the catalog needs serializing. - "unable to accept connection" is a prefix shared by five conditions. Only the Signature expired hint means the token is stale, and regenerating cannot fix a skewed host clock. HINT is a separate protocol field, so the guidance names it per driver. - An EOF at or after COMMIT leaves the commit outcome unknown, so a bare retry can double-write. Adds what to do when the outcome cannot be established at all. - dsql_lint strips index sort order rather than preserving it, and the severity depends on the direction: DESC is a warning, ASC is neither an error nor a warning, so gating on either counter misses the rewrite. Gate on the diagnostic list instead. Corrects key size to about 1,981 usable bytes, shared across all key columns and with any secondary index, and the 24-index limit to include the primary key, so a table with one accepts 23 secondary. Both confirmed against a cluster: a 500-byte primary key leaves 1,455 bytes for a secondary index key, and totals hold near 1,955 whatever the split. Key size, temp space and view-definition size all differ from the quotas page; each row now says so. Secondary indexes also count against the 10 MiB transaction limit, which is documented nowhere. Accounted size runs about 150 bytes per row above raw, plus about 430 per secondary index entry, measured linear across 0, 1, 2 and 4 indexes and predictive to within 1% from 200 to 2,900 rows. A 3,000-row batch on a four-index table loses roughly 5 MiB before any payload counts. The eval grader accepted "24 secondary indexes" and a fixture pinned it as passing, so the suite graded the error this commit fixes as a correct answer. It also failed correct answers laid out as a quoted table, bullet list or bolded number, which is what an eval requiring a documentation call tends to produce. The grader now matches the number and the index noun in either order, accepts bare "per table" and "can have" as bounds, and scopes the inclusive exemption to the statement that triggered the veto so one correct sentence no longer licenses a wrong claim elsewhere. Adds the excluding phrasings it missed: not including, doesn't count, plural primary keys, a fronted clause and an em dash. Six tests pin 20 phrasings. Adds five evals for the sort-order, transaction-size, EOF-at-COMMIT, catalog-change and key-size guidance, which had none. Also drops DESC from two index examples that return 0A000 today. A direction-free index serves a uniformly ordered ORDER BY by backward scan when the keys are a leading prefix and the projection is covered; mixed ordering adds a sort. Bumps the plugin version to 1.10.0. --- .claude-plugin/marketplace.json | 2 +- .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- plugins/databases-on-aws/skills/dsql/SKILL.md | 44 +- .../references/auth/authentication-guide.md | 5 +- .../dsql/references/development-guide.md | 2 +- .../skills/dsql/references/dsql-lint.md | 7 +- .../skills/dsql/references/examples/schema.md | 2 +- .../dsql/references/occ-retry-patterns.md | 64 ++- .../pg-migrations/index-conversion.md | 107 ++++- .../references/pg-migrations/multi-region.md | 13 +- .../pg-migrations/schema-objects.md | 5 +- .../skills/dsql/references/troubleshooting.md | 316 ++++++++++++-- tools/evals/databases-on-aws/README.md | 13 +- tools/evals/databases-on-aws/dsql/evals.json | 69 ++- .../dsql/scripts/run_functional_evals.py | 172 +++++++- .../dsql/scripts/test_run_functional_evals.py | 407 ++++++++++++++++++ 17 files changed, 1140 insertions(+), 92 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 83341dd5..b0c971b0 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -191,7 +191,7 @@ "serverless", "postgresql" ], - "version": "1.9.1" + "version": "1.10.0" }, { "category": "deployment", diff --git a/plugins/databases-on-aws/.claude-plugin/plugin.json b/plugins/databases-on-aws/.claude-plugin/plugin.json index b5a42d2e..7d947740 100644 --- a/plugins/databases-on-aws/.claude-plugin/plugin.json +++ b/plugins/databases-on-aws/.claude-plugin/plugin.json @@ -22,5 +22,5 @@ "license": "Apache-2.0", "name": "databases-on-aws", "repository": "https://github.com/awslabs/agent-plugins", - "version": "1.9.1" + "version": "1.10.0" } diff --git a/plugins/databases-on-aws/.codex-plugin/plugin.json b/plugins/databases-on-aws/.codex-plugin/plugin.json index 9c1b8b19..b5e89305 100644 --- a/plugins/databases-on-aws/.codex-plugin/plugin.json +++ b/plugins/databases-on-aws/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "databases-on-aws", - "version": "1.9.1", + "version": "1.10.0", "description": "Expert database guidance for the AWS database portfolio. Design schemas, execute queries, handle migrations, and choose the right database for your workload.", "author": { "name": "Amazon Web Services", diff --git a/plugins/databases-on-aws/skills/dsql/SKILL.md b/plugins/databases-on-aws/skills/dsql/SKILL.md index e9a4693d..fbad42c6 100644 --- a/plugins/databases-on-aws/skills/dsql/SKILL.md +++ b/plugins/databases-on-aws/skills/dsql/SKILL.md @@ -18,16 +18,16 @@ Load these files as needed for detailed guidance: ### Core: -| Reference | When to Load | Contains | -| --------------------------------------------------------- | --------------------------------------------------- | ---------------------------------------------------------------------------------------- | -| [development-guide.md](references/development-guide.md) | ALWAYS before schema changes or DB operations | Best practices, DDL rules, transaction limits, foreign key constraints | -| [foreign-keys.md](references/foreign-keys.md) | MUST load for foreign key operations or migrations | FK syntax, actions, validation, tenant keys | -| [language.md](references/language.md) | MUST load for language-specific choices | Driver selection, DSQL Connectors, connection code | -| [access-control.md](references/access-control.md) | MUST load for roles, grants, or sensitive data | Scoped role setup, IAM-to-database role mapping | -| [troubleshooting.md](references/troubleshooting.md) | SHOULD load for errors or unexpected behavior | OCC and `23503` errors, FK validation, connection failures, cluster state, DDL rejection | -| [dsql-examples.md](references/dsql-examples.md) | Load for implementation examples | Multi-tenant access, batch operations, identity and sequences, connection pooling | -| [onboarding.md](references/onboarding.md) | User requests "Get started with DSQL" | Interactive step-by-step guide | -| [occ-retry-patterns.md](references/occ-retry-patterns.md) | MUST load for OCC retry code or conflict mitigation | Connectors, `40001` retry, non-retryable `23503`, FK read conflicts, idempotent design | +| Reference | When to Load | Contains | +| --------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [development-guide.md](references/development-guide.md) | ALWAYS before schema changes or DB operations | Best practices, DDL rules, transaction limits, foreign key constraints | +| [foreign-keys.md](references/foreign-keys.md) | MUST load for foreign key operations or migrations | FK syntax, actions, validation, tenant keys | +| [language.md](references/language.md) | MUST load for language-specific choices | Driver selection, DSQL Connectors, connection code | +| [access-control.md](references/access-control.md) | MUST load for roles, grants, or sensitive data | Scoped role setup, IAM-to-database role mapping | +| [troubleshooting.md](references/troubleshooting.md) | SHOULD load for errors or unexpected behavior | Limits/SQLSTATEs with exact messages, OCC and `23503`, FK, connections, cluster state | +| [dsql-examples.md](references/dsql-examples.md) | Load for implementation examples | Multi-tenant access, batch operations, identity and sequences, connection pooling | +| [onboarding.md](references/onboarding.md) | User requests "Get started with DSQL" | Interactive step-by-step guide | +| [occ-retry-patterns.md](references/occ-retry-patterns.md) | MUST load for OCC retry code or conflict mitigation | Connectors, `40001` retry, non-retryable `23503`, FK read conflicts, idempotent design | ### MCP: @@ -56,12 +56,12 @@ Load these files as needed for detailed guidance: ### PostgreSQL Migrations: -| Reference | When to Load | Contains | -| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | -| [pg-migrations/type-mapping.md](references/pg-migrations/type-mapping.md) | MUST load for DSQL NUMERIC or PG type questions | C collation rules, NUMERIC(p,s), JSON/JSONB | -| [pg-migrations/index-conversion.md](references/pg-migrations/index-conversion.md) | MUST load for any index conversion or index support question | GIN/GiST/BRIN → btree, operator class removal, partial + expression indexes | -| [pg-migrations/schema-objects.md](references/pg-migrations/schema-objects.md) | MUST load for ENUM, materialized views, extensions, multi-schema, CREATE FUNCTION | ENUM → CHECK, views, role/IAM mapping, LANGUAGE sql functions | -| [pg-migrations/multi-region.md](references/pg-migrations/multi-region.md) | Multi-region, active-active, or HA questions | Architecture, geographic partitioning | +| Reference | When to Load | Contains | +| --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| [pg-migrations/type-mapping.md](references/pg-migrations/type-mapping.md) | MUST load for DSQL NUMERIC or PG type questions | C collation rules, NUMERIC(p,s), JSON/JSONB | +| [pg-migrations/index-conversion.md](references/pg-migrations/index-conversion.md) | MUST load for any index conversion or index support question | GIN/GiST/BRIN → btree, operator class removal, partial + expression indexes, sort order | +| [pg-migrations/schema-objects.md](references/pg-migrations/schema-objects.md) | MUST load for ENUM, materialized views, extensions, multi-schema, CREATE FUNCTION | ENUM → CHECK, views, role/IAM mapping, LANGUAGE sql functions | +| [pg-migrations/multi-region.md](references/pg-migrations/multi-region.md) | Multi-region, active-active, or HA questions | Architecture, geographic partitioning | ### ORM Guides: @@ -153,12 +153,16 @@ defaults that may change — when a user's decision depends on an exact limit, v | Max connections per cluster | 10,000 | `aurora dsql connection limits` | | Auth token expiry | 15 minutes | `aurora dsql authentication token` | | Max connection duration | 60 minutes | `aurora dsql connection limits` | -| Max indexes per table | 24 | `aurora dsql index limits` | +| Max indexes per table | 24 incl. PK | `aurora dsql index limits` | | Max columns per index | 8 | `aurora dsql index limits` | | IDENTITY/SEQUENCE CACHE values | 1 or >= 65536 | `aurora dsql sequence cache` | | Supported column data types | See docs | `aurora dsql supported data types` | -**When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. +That the 24 index limit counts the primary key — leaving 23 secondary indexes — is observed +behaviour; the quotas page states only the 24, so `awsknowledge` will not confirm the inclusive +framing. + +**When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. For the full limit set with each SQLSTATE and its exact error message, see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes). **Fallback:** If `awsknowledge` is unavailable, use the defaults above and flag that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). @@ -287,10 +291,10 @@ MUST load [system-diagnostics/workflow.md](references/system-diagnostics/workflo - **`awsknowledge` returns no results:** Use the default limits in the table above and note that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). - **`dsql_lint` unavailable or timing out:** See the Error Handling section of [dsql-lint.md](references/dsql-lint.md). Do not silently skip validation — inform the user and require explicit confirmation before proceeding with manual rules from [development-guide.md](references/development-guide.md). -- **OCC serialization error:** Retry the transaction. If persistent, check for hot-key contention — see [troubleshooting.md](references/troubleshooting.md). +- **OCC serialization error:** Retry the transaction. If persistent, check for hot-key contention. If the message contains `(OC001)` the cause is a schema catalog change — any DDL, and also `GRANT`/`REVOKE` — not row contention. A one-shot change clears on the first retry; if it persists, another session is still changing the catalog and you **MUST** serialize those changes. Match `(OC001)` as a substring, not as a suffix: some drivers append their own text such as `(SQLSTATE 40001)`. See [troubleshooting.md](references/troubleshooting.md). - **Foreign key violation (`23503`):** Correct the relationship or referential action; **MUST NOT** send it through the `40001` retry loop — see [troubleshooting.md](references/troubleshooting.md). -- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB; locked-row primary keys count toward the size limit — see [batched-migration.md](references/ddl-migrations/batched-migration.md). +- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB; locked-row primary keys count toward the size limit. The 10 MiB check runs at `COMMIT` against a size that exceeds the raw bytes by ~150 bytes per row plus ~430 bytes per secondary index entry, so an indexed table has a much smaller usable budget — see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes) for the formula, and [batched-migration.md](references/ddl-migrations/batched-migration.md) for the batching pattern. - **Token expiration mid-operation:** Generate a fresh IAM token — see [authentication-guide.md](references/auth/authentication-guide.md). See [troubleshooting.md](references/troubleshooting.md) for other issues. ## Additional Resources diff --git a/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md b/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md index 62aa9e43..e35d9f6c 100644 --- a/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md +++ b/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md @@ -45,7 +45,10 @@ Part of [DSQL Development Guide](../development-guide.md). **Best practices:** - Keep authentication tokens in memory only; discard after use -- Regenerate token on connection errors +- Regenerate the token only when the error indicates a stale one — `unable to accept connection, + access denied` carrying a `Signature expired` hint. The same message without that hint means wrong + credentials, region, or IAM permissions, and a fresh token will not help. See + [troubleshooting.md](../troubleshooting.md#connection-and-authorization) - Monitor token generation failures - Set connection timeouts appropriately diff --git a/plugins/databases-on-aws/skills/dsql/references/development-guide.md b/plugins/databases-on-aws/skills/dsql/references/development-guide.md index 48885d67..1f64b225 100644 --- a/plugins/databases-on-aws/skills/dsql/references/development-guide.md +++ b/plugins/databases-on-aws/skills/dsql/references/development-guide.md @@ -72,7 +72,7 @@ effortless scaling, multi-region viability, among other advantages. - REQUIRED: **at most one DDL statement** per operation - ALWAYS separate schema (DDL) and data (DML) changes - MUST use **`CREATE INDEX ASYNC`:** No synchronous creation (verify limits via `awsknowledge`: `aurora dsql index limits`) - - MAXIMUM: **24 indexes per table** + - MAXIMUM: **24 indexes per table, counting the primary key** — so 23 secondary indexes. The quotas page states only the 24; that it counts the primary key is observed behaviour. Indexes still building (`indisvalid = f`) occupy a slot; `DROP INDEX` frees one - MAXIMUM: **8 columns per index** - **MUST** verify index is ready before relying on it: `SELECT indisvalid FROM pg_index WHERE indexrelid = 'index_name'::regclass` — queries work but skip the index until `indisvalid = true` - MUST use **`ALTER TABLE ASYNC ... VALIDATE CONSTRAINT`** for constraint validation: No synchronous validation diff --git a/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md b/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md index 66ce9321..6fad3e90 100644 --- a/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md +++ b/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md @@ -43,13 +43,18 @@ Concrete example (from `dsql_lint(sql="CREATE INDEX idx ON t (c);", fix=true)`): **Schema notes:** -- `rule` is a snake_case string identifying the rule (e.g., `index_async`, `truncate`, `json_type`, `set_transaction`); `line` is 1-indexed. +- `rule` is a snake_case string identifying the rule (e.g., `index_async`, `index_sort_direction`, `truncate`, `json_type`, `set_transaction`); `line` is 1-indexed. - `fix_result.status` is one of three values: `fixed`, `fixed_with_warning`, or `unfixable`. Always check this field — `fix_result` is present for every diagnostic when `fix=true`. - `fix_result.detail` is present for `fixed` and `fixed_with_warning`; absent for `unfixable`. - `fixed_sql` contains rewritten SQL when the linter produces a rewrite. Do not assume it is present when no rewrite is needed. Presence of `fixed_sql` does NOT mean the SQL is safe to execute — check every diagnostic first. - `summary.errors` counts `unfixable` diagnostics; `summary.warnings` counts `fixed_with_warning`; `summary.fixed` counts `fixed`. +- **Do not gate a pipeline on any one counter.** Severity is derived from `fix_result.status`, so the + same rule can land in different counters depending on the input: `index_sort_direction` reports + `fixed_with_warning` for `DESC` but a silent `fixed` for `ASC`, even though DSQL rejects both. A + check on `errors == 0` or `warnings == 0` passes SQL that was rewritten underneath it. Gate on + `len(diagnostics) == 0`, or compare `fixed_sql` against the input. - `statement_preview` is the linter's pointer to the offending statement — useful when presenting diagnostics to the user. --- diff --git a/plugins/databases-on-aws/skills/dsql/references/examples/schema.md b/plugins/databases-on-aws/skills/dsql/references/examples/schema.md index 777fdd20..df6e779f 100644 --- a/plugins/databases-on-aws/skills/dsql/references/examples/schema.md +++ b/plugins/databases-on-aws/skills/dsql/references/examples/schema.md @@ -32,7 +32,7 @@ Both `JSONB` and `JSON` are valid; pick by access pattern (see Schema Design Rul ## Schema Design: Index Creation -MUST use `CREATE INDEX ASYNC` (max 24 indexes/table, 8 columns/index — verify via `awsknowledge`: `aurora dsql index limits`). Source: [aurora-dsql-samples/java/liquibase](https://github.com/aws-samples/aurora-dsql-samples/tree/main/java/liquibase) +MUST use `CREATE INDEX ASYNC` (max 24 indexes/table **including the primary key**, so 23 secondary; 8 columns/index — verify via `awsknowledge`: `aurora dsql index limits`). Source: [aurora-dsql-samples/java/liquibase](https://github.com/aws-samples/aurora-dsql-samples/tree/main/java/liquibase) ```sql CREATE INDEX ASYNC idx_owner_city ON owner(city); diff --git a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md index c48d8f5e..29214140 100644 --- a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md +++ b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md @@ -4,6 +4,10 @@ DSQL uses Optimistic Concurrency Control (OCC). Write transactions are validated COMMIT time — if another transaction modified the same rows, COMMIT fails with `SQLSTATE 40001` (serialization failure). Every application MUST implement retry logic. +A **catalog change** raises the same `40001`, so the code is not on its own enough to tell which +happened. The trailing marker in the message is the discriminator — see +[Retry Strategy](#retry-strategy). + ## Table of Contents 1. [Retry Strategy](#retry-strategy) @@ -22,10 +26,44 @@ Base delay: 50ms (allows concurrent transaction to commit) Backoff: exponential with jitter Formula: delay = min(base * 2^attempt + random(0, base), max_delay) Max delay: 5000ms (stays under DSQL's 5-minute transaction timeout) -Retryable: SQLSTATE 40001 only +Retryable: SQLSTATE 40001 only (both markers — see below) Non-retryable: all other errors, including foreign key violation 23503 (raise immediately) ``` +`40001` carries two causes, distinguished by the trailing marker in the message. `(OC000)` is a +write conflict and backoff clears it. `(OC001)` means the transaction's snapshot predates a change +to the schema catalog. + +**The trigger is any catalog change, not only `CREATE`/`ALTER`/`DROP`, and not only a concurrent +one.** `GRANT` and `REVOKE` raise `OC001` just as `ALTER TABLE` and `CREATE INDEX ASYNC` do — DSQL +groups them with DDL itself, rejecting a pair with `multiple ddl statements not supported in a +transaction` — and it fires whether the change is still in flight or already finished. What matters +is that the session pinned its snapshot before the change, not that anything overlapped in time. A +migration that only adjusts privileges will raise it, so do not go looking for a `CREATE` or +`ALTER` that is not there. + +The +[DDL and distributed transactions](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-ddl.html) +guide states the completed-change case directly: "An OC001 response can also occur when the schema +change has already completed before the affected transaction starts… On retry, the session refreshes +its catalog cache and the transaction typically succeeds." + +**Only transactions that write are affected.** A transaction that reads and commits without writing +is never hit, whether the read is in an implicit transaction or an explicit `BEGIN READ ONLY`, so a +read-only reporting workload needs no `OC001` handling at all. + +Because a retry takes a fresh snapshot, a one-shot catalog change clears on the first retry. It +keeps recurring only while another session keeps issuing changes — serialize those rather than +relying on retries. Both markers **MUST** stay in the retry loop: excluding `OC001` turns a +one-shot collision that the first retry would have cleared into a hard application error. Capture +the marker before each sleep so an exhausted loop records which cause it was retrying. See +[troubleshooting.md](troubleshooting.md#error-schema-has-been-updated-by-another-transaction-oc001). + +An error that arrives as a dropped connection rather than a SQLSTATE — `SSL SYSCALL error: EOF +detected` — leaves the commit outcome **unknown**, because the connection closed without a reply. +Do not feed it to this loop. Reconnect and confirm whether the write landed, or make the write +idempotent by design first; see [Idempotent Transaction Design](#idempotent-transaction-design). + --- ## DSQL Connectors (Preferred) @@ -51,9 +89,11 @@ When using a DSQL Connector, OCC retry is built in — no manual retry wrapper n Use when a DSQL Connector is not available or when custom retry behavior is required: ```python -import time, random, psycopg2 +import logging, time, random, psycopg2 from psycopg2 import errors +log = logging.getLogger(__name__) + def execute_with_retry(conn_params, operation, max_retries=5): """Execute a database operation with OCC retry.""" for attempt in range(max_retries): @@ -64,12 +104,27 @@ def execute_with_retry(conn_params, operation, max_retries=5): operation(cur) conn.commit() return - except errors.SerializationFailure: + except errors.SerializationFailure as error: conn.rollback() + # Both markers are 40001 and both belong in this loop, but they mean + # different things operationally. Logging the marker is what lets an + # exhausted loop be diagnosed: a one-shot catalog change clears on the + # first retry, so an OC001 that survives the loop means something keeps + # changing the catalog, and tuning row contention will never fix it. + marker = "OC001" if "OC001" in str(error) else "OC000" if attempt < max_retries - 1: delay = min(0.05 * (2 ** attempt) + random.uniform(0, 0.05), 5.0) + log.warning( + "40001 (%s) on attempt %d/%d, retrying in %.3fs", + marker, attempt + 1, max_retries, delay, + ) time.sleep(delay) else: + log.error( + "40001 (%s) exhausted %d attempts%s", + marker, max_retries, + "; serialize the catalog changes" if marker == "OC001" else "", + ) raise except Exception: conn.rollback() @@ -79,7 +134,8 @@ def execute_with_retry(conn_params, operation, max_retries=5): ``` The same pattern applies in any language — catch SQLSTATE 40001, apply exponential backoff -with jitter, retry up to the max. See the [DSQL code samples](https://github.com/aws-samples/aurora-dsql-samples) +with jitter, retry up to the max, and record the `(OC000)`/`(OC001)` marker so an exhausted loop +can be told apart from row contention. See the [DSQL code samples](https://github.com/aws-samples/aurora-dsql-samples) for Java, Go, Node.js, and Rust implementations. --- diff --git a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md index d992eb17..42cd200e 100644 --- a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md +++ b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md @@ -1,7 +1,9 @@ # Index Conversion for DSQL Run `dsql_lint(fix=true)` first — it handles most index conversions automatically (ASYNC, -USING gin/gist/brin/hash → btree, CONCURRENTLY removal, INCLUDE preservation, sort order). +USING gin/gist/brin/hash → btree, CONCURRENTLY removal, INCLUDE preservation, and **stripping** +`ASC`/`DESC` from index keys — DSQL rejects sort direction outright, see +[Index Key Sort Order](#index-key-sort-order)). This file covers the cases a mechanical fix cannot resolve: @@ -10,6 +12,10 @@ This file covers the cases a mechanical fix cannot resolve: - Operator classes such as `text_pattern_ops` — `dsql_lint` leaves these in place with no diagnostic, so strip them by hand. DSQL rejects them with `ERROR: 0A000: opclass not supported for index keys` +- Sort direction — `index_sort_direction`; auto-fixed, but the fix changes which `ORDER BY` the + index serves, and for `ASC` it is reported as neither an error nor a warning. See + [Index Key Sort Order](#index-key-sort-order). NULLS placement is **not** part of this rule and + is never stripped — see [NULLS placement is retained](#nulls-placement-is-retained). Partial indexes (`WHERE`) and expression indexes are **supported** — add `ASYNC` and keep them as written. No redesign is needed. @@ -17,6 +23,7 @@ written. No redesign is needed. Sources: - [Asynchronous Indexes](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-create-index-async.html) +- [CREATE INDEX syntax support](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/create-index-syntax-support.html) - [DSQL SQL Dialect Blog](https://aws.amazon.com/blogs/database/dsql-sql-dialect-how-amazon-aurora-dsql-differs-from-single-instance-postgresql/) ## Table of Contents @@ -27,8 +34,9 @@ Sources: 4. [Partial Indexes](#partial-indexes) 5. [Expression Indexes](#expression-indexes) 6. [Index Limits](#index-limits) -7. [Monitoring Async Index Status](#monitoring-async-index-status) -8. [Conversion Decision Flowchart](#conversion-decision-flowchart) +7. [Index Key Sort Order](#index-key-sort-order) +8. [Monitoring Async Index Status](#monitoring-async-index-status) +9. [Conversion Decision Flowchart](#conversion-decision-flowchart) --- @@ -154,8 +162,9 @@ CREATE INDEX idx_logs_created ON logs USING brin (created_at); CREATE INDEX ASYNC idx_logs_created ON logs (created_at); -- If the table is very large and you need to limit index size, --- use a composite index with the most selective column first: -CREATE INDEX ASYNC idx_logs_tenant_created ON logs (tenant_id, created_at DESC); +-- use a composite index with the most selective column first. +-- No ASC/DESC on index keys — see "Index Key Sort Order" below. +CREATE INDEX ASYNC idx_logs_tenant_created ON logs (tenant_id, created_at); ``` --- @@ -236,11 +245,16 @@ to be IMMUTABLE anyway. ## Index Limits -| Limit | Value | -| --------------------- | ----- | -| Max indexes per table | 24 | -| Max columns per index | 8 | -| Max PK/index key size | 1 KiB | +| Limit | Value | SQLSTATE | Message when exceeded | +| --------------------- | ------------------------------------- | -------- | ------------------------------------------------------- | +| Max indexes per table | 24, **incl. the PK** | `54000` | `more than 24 indexes per table are not allowed` | +| Max columns per index | 8 | `54011` | `more than 8 column keys in an index are not supported` | +| Max PK/index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | + +The primary key occupies one of the 24 slots, so a table with a PK accepts 23 secondary indexes. +Indexes still building (`indisvalid = f`) occupy a slot too, so that budget is spent at submission +and waiting does not recover it. For the key-size derivation, the multi-column budget and the rest +of the limit set, see [troubleshooting.md](../troubleshooting.md#limits-and-error-codes). **Strategy when approaching 24 index limit:** @@ -251,6 +265,76 @@ to be IMMUTABLE anyway. --- +## Index Key Sort Order + +**DSQL rejects `ASC`/`DESC` on index keys** — both of them, with +`ERROR: 0A000: specifying sort order not supported for index keys`. `dsql_lint` reports this as rule +`index_sort_direction` and `fix=true` strips the direction, but **the severity depends on which +direction you wrote**: + +| Input | `fix_result.status` | `summary` | +| ----------------- | -------------------- | ------------------------------------ | +| `created_at DESC` | `fixed_with_warning` | `{errors: 0, warnings: 1, fixed: 0}` | +| `created_at ASC` | `fixed` | `{errors: 0, warnings: 0, fixed: 1}` | + +So a pipeline that gates on `errors == 0`, or that consumes only `fixed_sql`, proceeds without +surfacing that the statement changed — and for `ASC` even a stricter pipeline gating on +`warnings == 0` proceeds, because `ASC` raises no warning at all. Gate on +`len(diagnostics) == 0`, or compare `fixed_sql` against the input, rather than on either counter. + +```sql +-- PostgreSQL +CREATE INDEX idx_logs_tenant_created ON logs (tenant_id, created_at DESC); + +-- DSQL: ERROR: 0A000: specifying sort order not supported for index keys +-- Drop the direction: +CREATE INDEX ASYNC idx_logs_tenant_created ON logs (tenant_id, created_at); +``` + +**This costs no capability for a uniformly-ordered `ORDER BY`.** The planner reads an index +backwards, so a single direction-free index serves both directions. Against +`(tenant_id, created_at)`, with a projection the index covers: + +| `ORDER BY` | Plan | +| --------------------------------- | ------------------------------------------------------------- | +| `tenant_id, created_at` | `Index Only Scan` | +| `tenant_id DESC, created_at DESC` | `Index Only Scan Backward` | +| `tenant_id, created_at DESC` | `Incremental Sort` above the scan, `Presorted Key: tenant_id` | +| `tenant_id DESC, created_at` | `Incremental Sort` above a backward scan | + +**The projection matters as much as the direction.** The plans above hold for +`SELECT tenant_id, created_at`. A secondary index is not covering, so `SELECT *` adds a `Sort` above +a primary-key scan in _all four_ cases — the ordering is lost to the column lookup, not to the +direction. Use `INCLUDE` to cover the projection, or accept the sort. + +The rule is **direction uniformity over a leading prefix** — uniformity alone is not enough. An +`ORDER BY` is served by a scan when its keys are a leading prefix of the index keys _and_ all point +the same way. `ORDER BY created_at` is perfectly uniform and still gets a full `Sort` against +`(tenant_id, created_at)`, because `created_at` is not a prefix. A _mixed_-direction `ORDER BY` over +a prefix keeps the `Incremental Sort`, and no DSQL index can remove it, because index keys carry no +direction to match against. If a mixed ordering is on a hot path, either sort in the application or +store an inverted column (for example a negated numeric, or a computed descending rank) and order on +it uniformly. + +### NULLS placement is retained + +`NULLS FIRST` / `NULLS LAST` **is** accepted on an index key, and `dsql_lint` keeps it — only +`ASC`/`DESC` are removed. A backward scan flips the NULLS placement along with the direction, so +an index serves exactly two orderings: its own placement ascending, and the opposite placement +descending. A default index (`NULLS LAST`) therefore covers the two default forms: + +| Index | Served by forward scan | Served by backward scan | +| ----------------- | ------------------------ | ---------------------------- | +| `(a)` | `ORDER BY a` | `ORDER BY a DESC` | +| `(a NULLS FIRST)` | `ORDER BY a NULLS FIRST` | `ORDER BY a DESC NULLS LAST` | + +**Only add `NULLS FIRST` to serve a query that explicitly asks for it.** An index declared +`NULLS FIRST` no longer satisfies the plain `ORDER BY a` or `ORDER BY a DESC` — both fall back to +a `Sort` — so adding one to fix a single query can cost the default ordering on every other. +Nullable columns need no special handling otherwise: the default index covers both directions. + +--- + ## Monitoring Async Index Status Indexes created with ASYNC are not immediately usable. Monitor: @@ -273,7 +357,8 @@ WHERE NOT indisvalid; ``` Is it a btree index? -├── Yes → CREATE INDEX ASYNC (preserve columns, INCLUDE, sort order, WHERE, IMMUTABLE expressions) +├── Yes → CREATE INDEX ASYNC (preserve columns, INCLUDE, WHERE, IMMUTABLE expressions; +│ drop ASC/DESC — a backward scan serves uniformly-descending ORDER BY) │ ├── Is it GIN? │ ├── For JSONB containment → unindexed at runtime; expression index for key equality diff --git a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md index 74755300..18c3843b 100644 --- a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md +++ b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md @@ -69,8 +69,11 @@ contention across regions — partition data by geography where possible. ## Quotas -| Quota | Value | -| --------------------------------- | ----------------------------------------------------------------------------------------- | -| Multi-region clusters per account | 5 (increasable) | -| Cluster topology | Two peered clusters in two endpoint Regions, plus one shared witness Region (no endpoint) | -| Storage per cluster | 10 TiB (up to 256 TiB) | +Full limit set, with the SQLSTATE and exact message for each database limit: +[troubleshooting.md](../troubleshooting.md#limits-and-error-codes). + +| Quota | Value | +| --------------------------------------------- | ----------------------------------------------------------------------------------------- | +| Multi-region clusters per account, per Region | 5 (increasable) | +| Cluster topology | Two peered clusters in two endpoint Regions, plus one shared witness Region (no endpoint) | +| Storage per cluster | 10 TiB (up to 256 TiB) | diff --git a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/schema-objects.md b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/schema-objects.md index 9eca6b8a..9fe67737 100644 --- a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/schema-objects.md +++ b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/schema-objects.md @@ -166,7 +166,10 @@ CREATE TABLE events ( created_at timestamptz NOT NULL DEFAULT now(), data jsonb ); -CREATE INDEX ASYNC idx_events_tenant_created ON events (tenant_id, created_at DESC); +-- No ASC/DESC on index keys — DSQL rejects sort direction with +-- 0A000: specifying sort order not supported for index keys. +-- A backward scan still serves a uniformly-descending ORDER BY on these keys. +CREATE INDEX ASYNC idx_events_tenant_created ON events (tenant_id, created_at); ``` **Note:** DSQL's PK-ordered storage and distributed architecture handle data distribution diff --git a/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md b/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md index 7b9a2be2..8b0e9604 100644 --- a/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md +++ b/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md @@ -8,30 +8,238 @@ Before referring to any listed error, use the routing below and consult ## Table of Contents -1. [Connection and Authorization](#connection-and-authorization) -2. [Cluster Lifecycle](#cluster-lifecycle) -3. [Foreign Key Addition or Validation Fails](#foreign-key-addition-or-validation-fails) -4. [Incompatibility](#incompatibility) -5. [Protocol Compatibility](#protocol-compatibility) -6. [Additional Resources](#additional-resources) +1. [Limits and Error Codes](#limits-and-error-codes) +2. [Connection and Authorization](#connection-and-authorization) +3. [Cluster Lifecycle](#cluster-lifecycle) +4. [Foreign Key Addition or Validation Fails](#foreign-key-addition-or-validation-fails) +5. [Incompatibility](#incompatibility) +6. [Protocol Compatibility](#protocol-compatibility) +7. [Additional Resources](#additional-resources) + +## Limits and Error Codes + +**Within this section, match on the message rather than the code.** `54000` and `0A000` each cover +more than a dozen of the rows below, so the code alone does not identify which row you are on. This +is a lookup rule for these tables, not a retry rule — classification elsewhere in the skill is +SQLSTATE-based, and `40001` remains the only retryable code (see +[occ-retry-patterns.md](occ-retry-patterns.md)). + +**MUST** match a stable prefix, not the whole string. ``, `` and `...` in the Message +column are values DSQL interpolates at run time, so an equality test against the cell never +matches. If no message matches, **SHOULD** fall back to the SQLSTATE class and verify the current +limit via `awsknowledge` — message text is not a stable contract. + +Values marked **observed** were reproduced against a single-Region cluster on 2026-10-01 +(PostgreSQL 16 compatibility). Where an observed value contradicts the +[quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html), +the row says so. Re-verify before a design decision depends on an exact number. + +Run `dsql_lint` to catch unsupported _features_ before execution; use this section for the +_limits_, which are only observable at run time. + +### Exceeded Limits + +| Limit | Value | SQLSTATE | Message | +| ----------------------------------- | ----------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| Row modifications per transaction | 3,000 | `54000` | `transaction row limit exceeded` | +| Transaction size | 10 MiB, incl. per-row and per-index overhead (see below) | `54000` | `transaction size limit 10mb exceeded`, with `DETAIL: Current transaction size mb > 10mb` | +| Transaction age | 5 minutes | `54000` | `transaction age limit of 300s exceeded`, with `DETAIL: Current transaction age s exceeds 300s` | +| Query temp space | 128 MiB; docs give `53200` and other text | `54000` | `query requires more than allowed temp space of 131072 KB to execute` | +| Extended statistics per table | 5 | `54000` | `more than 5 extended statistics per table are not allowed` | +| Schemas per database | 10, excluding `public` and `sys` | `54000` | `more than 10 schemas not allowed` | +| Indexes per table | 24 **including the primary key** | `54000` | `more than 24 indexes per table are not allowed` | +| Primary key or index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | +| Row size | 2 MiB | `54000` | `maximum row size exceeded` | +| Non-index column size | 1 MiB; docs give the message below, a per-datatype one was seen | `54000` | docs: `maximum column size exceeded`; observed: `datatype limit greater than 1048576 bytes not supported for ` | +| View definition size | 131–524 KiB of SQL text observed, varies by shape; docs say 2 MiB | `54000` | `view definition too large` | +| Expression nesting depth | Reached before any size limit | `54001` | `stack depth limit exceeded` | +| `char` declared size | 4,096 bytes, rejected at DDL time | `54000` | `Datatype limit greater than 4096 bytes not supported for char` | +| `varchar` declared size | 65,535 bytes, rejected at DDL time | `54000` | `Datatype limit greater than 65535 bytes not supported for varchar` | +| `text` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for text` | +| `bytea` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for bytea` | +| `json` / `jsonb` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for jsonb` | +| Columns per index or primary key | 8 | `54011` | `more than 8 column keys in an index are not supported` | +| Columns per table, no primary key | 256 accepted, 257 rejected; docs and message both say 255 | `54011` | `tables can have at most 255 columns` | +| Columns per table, with primary key | 256 accepted, 257 rejected; message names an _index_ | `54011` | `cannot use more than 256 columns in an index` | +| Extended-statistics target | 100 | `22023` | `statistics target exceeds maximum allowed value of 100` | +| `numeric` precision | 1,000 | `22023` | `NUMERIC precision must be between 1 and 1000` | + +Several of these behave in ways the limit alone does not convey: + +- **The per-value 1 MiB limit is a hard bound on high-entropy data.** For random `text`, `base64`, + `bytea` and `jsonb`, 1,048,576 bytes is the largest value accepted and 1,048,577 the first + rejected — nothing fails early. Compressible values go much further: a single `text` column + accepted 214 MiB of one repeated character. Size against raw byte count; compression only ever + buys headroom. +- **The 10 MiB transaction limit counts more than the payload bytes.** The check runs at `COMMIT` + against an accounted size that adds a fixed cost per row, plus a further cost for every + secondary index entry the write produces: + + ```text + accounted ~= raw + rows x (~150 bytes + ~430 bytes per secondary index) + ``` + + Observed with 500 rows and the payload in an unindexed column: 9.93 MiB of payload committed + with no secondary index, 9.73 MiB with one, 9.52 MiB with two, and 9.11 MiB with four — a + straight line at ~429 bytes per index entry. The formula predicted the ceiling at 200, 1,500 + and 2,900 rows to within 1%. **Indexes dominate at scale:** a 3,000-row batch on a table with + four secondary indexes loses ~5 MiB of its budget to index writes before any payload is + counted, so the same statement that commits against a bare table fails against an indexed one. + Note the contrast with the row limit, which the documentation says applies "regardless of the + number of secondary indexes" — that independence does **not** hold for the size limit, and + neither the per-row nor the per-index cost is documented. Budget from the formula rather than + the raw byte count; the accounted size is always above raw, never below, so headroom computed + this way is safe. +- **The key-size budget is combined, in bytes, across every key column.** One column reaches + ~1,981 bytes; each additional key column costs ~8 more, so an 8-column key gets ~241 bytes + each. Bytes, not characters — a 4-byte UTF-8 character key holds only ~495 characters. For a + secondary index the table's primary key columns count against the same budget: the combined + ceiling measures ~1,955 bytes once a second key column exists, so a 500-byte primary key leaves + ~1,455 bytes for the index columns. The quotas page lists the primary key and secondary index as + two separate 1 KiB budgets; the observed behaviour is a single shared budget. +- **The view-definition limit is a 512 KiB internal budget, not a text length.** How much SQL + fits depends on structure: a target list of literals reached ~524 KiB of definition text, an + `IN` list ~229 KiB, a `CASE` chain ~166 KiB, and a chain of OR-ed comparisons only ~131 KiB. The + text length is therefore not a reliable predictor in either direction — a differently-shaped + OR-chain was rejected at ~127 KiB. Treat **~120 KiB** as a working ceiling, but size by trial + rather than by character count when a view approaches it. Deeply nested expressions hit + `54001` first. +- **Indexes still building count.** An index with `indisvalid = f` occupies one of the 24 slots, + so the budget is spent at submission and waiting does not recover it. `DROP INDEX` frees a slot. +- **`char` and `varchar` limits are enforced on the _declared_ size at DDL time**, so an + oversized declaration fails at `CREATE TABLE`, not on first write. +- **Dropped columns keep counting.** The documented ceiling is 1,600 _cumulative_ columns including + dropped ones, so a table churned by repeated `ADD COLUMN`/`DROP COLUMN` during a migration can + hit `54011` while holding far fewer live columns. Recreate the table rather than continuing to + churn it. + +### Rejections and Constraint Violations + +Unsupported-feature list: [PostgreSQL compatibility — unsupported features](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html). Index syntax: [CREATE INDEX syntax support](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/create-index-syntax-support.html). + +These are not limits — the feature or the value is refused outright. + +| Cause | SQLSTATE | Message | +| --------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | +| Non-immutable function in an index expression | `42P17` | `functions in index expression must be marked IMMUTABLE` | +| Non-immutable generated-column expression | `42P17` | `generation expression is not immutable` | +| `serial` column type | `42704` | `type "serial" does not exist` | +| Array column type | `0A000` | `datatype text[] not supported` — the declared type is echoed back | +| Sort direction on an index key | `0A000` | `specifying sort order not supported for index keys` | +| Operator class on an index key | `0A000` | `opclass not supported for index keys` | +| Index on an unsupported column type | `0A000` | `datatype is not supported in a key` | +| `CREATE INDEX` without `ASYNC` | `0A000` | `unsupported mode. please use CREATE INDEX ASYNC.` | +| `CREATE INDEX CONCURRENTLY` | `0A000` | `CONCURRENTLY not supported for CREATE INDEX` | +| `ALTER TABLE ADD COLUMN` with any inline constraint | `0A000` | `ALTER TABLE ADD COLUMN with constraint not supported` | +| `ALTER COLUMN ... SET DATA TYPE` | `0A000` | `unsupported ALTER TABLE ALTER COLUMN ... SET DATA TYPE statement` | +| `ALTER COLUMN ... SET STATISTICS`, at any value | `0A000` | `unsupported ALTER TABLE ALTER COLUMN ... SET STATISTICS statement` | +| `CREATE PROCEDURE` | `0A000` | `PROCEDURE is not supported` | +| `CREATE DATABASE` | `0A000` | `unsupported statement: Createdb` | +| Post-creation foreign key without `NOT VALID` | `0A000` | `unsupported ALTER TABLE ADD CONSTRAINT statement` | +| Synchronous `VALIDATE CONSTRAINT` | `0A000` | `unsupported ALTER TABLE VALIDATE CONSTRAINT statement` | +| Foreign key violation — **not retryable** | `23503` | `insert or update on table ... violates foreign key constraint ...` | +| Duplicate against a unique index still building | `23505` | `duplicate key value violates unique constraint ...` | +| Any statement issued after a failure inside `BEGIN` | `25P02` | `current transaction is aborted, commands ignored until end of transaction block` | +| Protocol message over 10 MiB | **none** | No error reaches the client — see below | + +These mislead in practice: + +- **`25P02` masks the real error.** It is the code an agent sees most often, because anything + that keeps issuing statements after a failure inside a transaction block gets it in place of + the original cause. Read the _first_ error in the transaction, not the last. +- **An oversized protocol message closes the connection with no error message.** The client sees + only `SSL SYSCALL error: EOF detected`, which is indistinguishable from a transient network + fault, so a naive retry resends the same oversized statement forever. Treat an EOF on the first + statement after a large one as a size problem, not a network one. Note the AWS documentation + lists `08P01` / `FATAL: invalid message length` for this case; that error is not delivered as + observed here. If you _do_ see `08P01: invalid message length`, that is this case and the + behaviour has changed — trust the error. + +**Any EOF at or after `COMMIT` leaves the commit outcome unknown**, whatever the cause, because the +connection closed before the server replied. This applies to the oversized-message case above and +equally to an EOF the agent classifies as a network fault — the size discriminator tells you why the +connection dropped, not whether the write landed. Before resending, an agent **MUST** reconnect and +confirm whether the write committed. Designing the write to be idempotent removes the need for that +check, but only if it was already idempotent — it is not something that can be applied after the +EOF arrives, and a bare retry of a non-idempotent write such as +`UPDATE accounts SET balance = balance - 100` double-applies it. See +[occ-retry-patterns.md](occ-retry-patterns.md#idempotent-transaction-design). + +**If the outcome cannot be confirmed, an agent MUST NOT retry.** A blind +`balance = balance - 100` has no observable trace that distinguishes "applied once" from "not +applied", so there is nothing to query. **MUST** report the unresolved write to the user with the +transaction's identifying values and stop; a duplicated debit is worse than a reported gap. Avoid the situation rather than resolve it: write a +correlation id the transaction itself records, so the question becomes answerable next time. + +The threshold is the _protocol message_ size, which a client does not see directly: a batched or +parameterised multi-row insert can exceed it well below 10 MiB of visible data. If you cannot +measure it, reduce the batch size until the EOF stops. + +### Cluster Quotas + +From the [Aurora DSQL quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html). +Unlike the tables above, these are **not verified here** — they need provisioned resources to +exercise, so treat the values as documented rather than observed. + +| Quota | Value | SQLSTATE | Message when exceeded | +| ---------------------------------------------- | ------------------------------ | -------- | --------------------------------------------------------- | +| Tables per database | 1,000 | `54000` | `creating more than 1000 tables not allowed` | +| Views per database | 5,000 | `54000` | `creating more than 5000 views not allowed` | +| Sequences per database | 5,000 | `54000` | `creating more than 5000 sequences is not allowed` | +| Single-Region clusters per account, per Region | 20 (increasable) | API | `You have reached the cluster limit.` | +| Multi-Region clusters per account, per Region | 5 (increasable) | API | `You have reached the cluster limit.` | +| Storage per cluster | 10 TiB, increasable to 256 TiB | `53100` | `Current cluster size exceeds cluster size limit.` | +| Connections per cluster | 10,000 | `53300` | `Unable to accept connection, too many open connections.` | +| New connections per second | 100 sustained, 1,000 burst | `53400` | `Unable to accept connection, rate exceeded.` | +| Concurrent restore jobs | 4 | — | No error code documented | +| CDC streams per cluster | 5 | API | `You have reached the stream limit.` | + +The cluster-count and CDC rows surface as the control-plane API error +`ServiceQuotaExceededException: 402`, not a SQLSTATE, so they never reach a SQL client. The three +`creating more than ...` messages are documented but were not exercised here; note they are the only +rows in this section whose capitalisation differs from the observed ones, which matters if you are +matching case-sensitively. ## Connection and Authorization -### Token Expiration +### Error: "FATAL: unable to accept connection, ..." -### Error: "Token has expired" +**Cause:** One of several conditions. `unable to accept connection` is a **shared prefix** — match +it case-insensitively, since the two connection-quota variants capitalise `Unable` and omit the +`FATAL:` prefix — then match the whole line before deciding whether to retry. -**Cause:** Authentication token older than 15 minutes -**Solutions:** +`HINT` is a **separate protocol field**, not part of the message line, so read it from the driver's +error object rather than by substring-matching the message: `err.diag.message_hint` (psycopg), +`pgErr.Hint` (Go `pgx`), `err.hint` (node-postgres). psycopg's string rendering happens to include +`HINT:`, so a substring match works there and nowhere else. + +| Full message | Meaning | Retry? | +| --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ----------------------------------------- | +| `FATAL: unable to accept connection, access denied` plus `HINT: Signature expired: ...` | The token is stale | Once, with a fresh token | +| `FATAL: unable to accept connection, access denied` with no `Signature expired` hint | Wrong credentials, region, or IAM permissions | No — regenerating the token will not help | +| `FATAL: unable to accept connection, waking up cluster, please retry later` | The cluster is `INACTIVE` — see [Cluster Lifecycle](#cluster-lifecycle) | Yes, after polling for `ACTIVE` | +| `Unable to accept connection, too many open connections.` (`53300`) | Cluster connection limit reached (10,000) | Yes, after releasing connections | +| `Unable to accept connection, rate exceeded.` (`53400`) | Connection rate limit reached (100/s sustained) | Yes, with backoff — not with a new token | + +The last two rows are from the quotas documentation and are **not verified here**; the first three +were reproduced against a cluster. An agent that matches only on `unable to accept connection` +retries an authorization failure indefinitely, and treats connection-pool exhaustion as an auth +problem. + +**When the `Signature expired` hint is present**, the token is stale. Refresh connections within +15 minutes and pick one of: - Auto-regenerate tokens per connection or query OR - Use connection pool hooks to refresh before expiration OR - Implement retry logic with token regeneration -**Additional Recommendations:** +**MUST** retry at most once with a fresh token. The token is a SigV4 pre-signed URL, so if the client +clock is behind by more than the token lifetime every freshly minted token is already outside the +server's window and the hint persists no matter how many you generate. If `Signature expired` +survives a fresh token, **SHOULD** check the host clock (NTP) rather than regenerating again. -- Refresh connections within 15 minutes -- Auto-reconnect after observing auth errors +For the other rows, regenerating the token does not help — resolve the IAM, region, cluster-state, +or connection-count condition the matched row names. ### Connection Timeouts @@ -98,20 +306,26 @@ action; **MUST NOT** route it through the `40001` OCC retry loop. When migrating from PostgreSQL, remember DSQL doesn't support: -- **SERIAL types** - Use `GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY` with sequences instead +- **SERIAL types** — `42704: type "serial" does not exist`. Use + `GENERATED { ALWAYS | BY DEFAULT } AS IDENTITY` with sequences instead. The message names a + missing _type_, not an unsupported feature, so it is easy to misread as a typo - **Extensions** - No PL/pgSQL, PostGIS, pgvector, etc. - **Triggers** - Implement logic in application layer - **Temporary tables** - Use regular tables or application-level caching - **TRUNCATE** - Use `DELETE FROM table` instead -- **Multiple databases** - Single `postgres` database per cluster +- **Multiple databases** — `0A000: unsupported statement: Createdb`. Single `postgres` database + per cluster - **Custom types** - Limited type system support - **Partitioning** - Manage data distribution in application -See [full list of unsupported features](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html). +See [full list of unsupported features](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html), +and [Rejections and Constraint Violations](#rejections-and-constraint-violations) for the exact +message each rejection produces. -### Error: "Datatype array not supported" +### Error: "datatype text[] not supported" -**Cause:** Using `TEXT[]` or other array column types +**Cause:** Using `TEXT[]` or other array column types. SQLSTATE `0A000`; the rejected type is +echoed back, so `integer[]` reports `datatype integer[] not supported`. **Solution:** Serialize the array into a single column — DSQL has no array column type. PREFER `JSONB`; MAY use `TEXT` for opaque columns. ASK the user which format fits the access pattern. - **PREFER `JSONB`** — the application queries inside the value (`@>`/`?`/`?|`/`?&`, `jsonb_array_elements_text`, or indexed JSONB paths); values are normalized on write. Insert: `INSERT INTO t (tags) VALUES ($1::jsonb)` with `JSON.stringify(arr)`. Query: `jsonb_array_elements_text(tags)`. @@ -119,36 +333,74 @@ See [full list of unsupported features](https://docs.aws.amazon.com/aurora-dsql/ - **`JSON` is valid** when writes dominate (no parse/sort overhead on write), byte-exact input matters (audit, replay, duplicate keys), or only `->`/`->>` is needed. - **When migrating:** keep existing `JSON` columns as `JSON`; upgrade to `JSONB` only when JSONB-only operators or indexed paths are needed. -### Error: "Please use CREATE INDEX ASYNC" +### Error: "unsupported mode. please use CREATE INDEX ASYNC." + +**Cause:** Creating an index without the `ASYNC` keyword. `CREATE INDEX CONCURRENTLY` is rejected +separately, with `CONCURRENTLY not supported for CREATE INDEX`. Both are SQLSTATE `0A000`. -**Cause:** Creating index without ASYNC keyword **Solution:** ```sql -- Wrong -CREATE INDEX idx_name ON table(column); +CREATE INDEX idx_logs_created_a ON logs (created_at); +CREATE INDEX CONCURRENTLY idx_logs_created_b ON logs (created_at); -- Correct -CREATE INDEX ASYNC idx_name ON table(column); +CREATE INDEX ASYNC idx_logs_created ON logs (created_at); ``` -### Error: "Transaction exceeds 3000 rows" +### Error: "transaction row limit exceeded" -**Cause:** Modifying too many rows in single transaction +**Cause:** Modifying more than 3,000 rows in a single transaction. SQLSTATE `54000`. **Solution:** -1. Batch operations into chunks of 500-1000 rows +1. Start from chunks of 500-1000 rows 2. Process each batch separately 3. Add WHERE clause to limit scope -### Error: "OC001 - Concurrent DDL operation" +Treat that chunk size as a starting point, not a guarantee. The same transaction is also bounded by +size and age — see [Exceeded Limits](#exceeded-limits) — and the size limit is checked at `COMMIT` +against an accounted size that runs above the raw byte count by roughly 150 bytes per row, plus +~430 bytes for every secondary index entry written. On a table carrying several secondary indexes +that overhead decides the chunk size, so compute the budget from the row width and the index count +rather than from the payload alone. + +### Error: "schema has been updated by another transaction (OC001)" + +**Cause:** The transaction's snapshot predates a change to the schema catalog. **One** change is +enough, and it does **not** have to be concurrent — a change that committed before the transaction +reached its next statement still raises it. + +**`OC001` is SQLSTATE `40001`** — the same code as an OCC write conflict, and the same code +`SKILL.md` routes into the retry loop. The two causes are distinguished only by the trailing +marker in the message: + +| Message | Marker | Cause | +| -------------------------------------------------------- | ------- | ------------------------------------------- | +| `change conflicts with another transaction (OC000)` | `OC000` | Concurrent DML on the same rows | +| `schema has been updated by another transaction (OC001)` | `OC001` | Any catalog change — DDL, `GRANT`, `REVOKE` | + +A single `ALTER TABLE ADD COLUMN`, a single `GRANT` and a single `REVOKE` were each observed to +raise it alone, after the change had already committed. So "my migration runs no DDL" does not rule +it out — though note DSQL itself counts `GRANT` and `REVOKE` as DDL when holding a transaction to a +single DDL statement, rejecting a pair with +`0A000: multiple ddl statements not supported in a transaction`. + +**Only transactions that write are affected.** A transaction that reads and commits without writing +is never hit, whether the read is in an implicit transaction or an explicit `BEGIN READ ONLY`, so a +read-only reporting workload needs no `OC001` handling at all. + +Both markers are retryable. A one-shot catalog change clears on the **first** retry, because the +retry takes a fresh snapshot; it keeps recurring only while another session keeps changing the +catalog, and then serializing those changes is the fix rather than more backoff. Note the conflict +is reported at `COMMIT`, and to the transaction that committed **second**, so the failing session +is not necessarily the one that started later. -**Cause:** Multiple DDL operations on same resource **Solution:** -1. Wait for current DDL to complete -2. Retry with exponential backoff -3. Execute DDL operations sequentially +1. Retry the transaction — a fresh snapshot is usually all it needs +2. Apply the same exponential backoff as any other `40001` +3. If it recurs across retries, serialize the catalog changes rather than widening the backoff ## Protocol Compatibility @@ -159,6 +411,10 @@ CREATE INDEX ASYNC idx_name ON table(column); - Use officially tested drivers from [aws-samples/aurora-dsql-samples](https://github.com/aws-samples/aurora-dsql-samples) - Test client compatibility before production deployment +A protocol message over 10 MiB is a separate failure with no error message at all — the +connection is closed and the client sees only `SSL SYSCALL error: EOF detected`. See +[Rejections and Constraint Violations](#rejections-and-constraint-violations). + ## Additional Resources - [Aurora DSQL troubleshooting guide](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/troubleshooting.html#troubleshooting-connections) diff --git a/tools/evals/databases-on-aws/README.md b/tools/evals/databases-on-aws/README.md index d904fccc..0c04caa4 100644 --- a/tools/evals/databases-on-aws/README.md +++ b/tools/evals/databases-on-aws/README.md @@ -14,7 +14,7 @@ their recorded results, and runner files: tools/evals/databases-on-aws/ ├── README.md # This file — top-level index └── dsql/ # Aurora DSQL skill evals - ├── evals.json # Tier 2: functional evals (22 prompts, 93 assertions) + ├── evals.json # Tier 2: functional evals (27 prompts, 118 assertions) ├── dsql_lint_evals.json # dsql_lint workflow (4 prompts, 20 assertions) ├── pg_migration_evals.json # PostgreSQL migrations (17 prompts, 90 assertions) ├── pg_migration_hallucination_evals.json # Migration hallucinations (3 prompts, 14 assertions) @@ -96,13 +96,13 @@ mise exec -- python tools/evals/databases-on-aws/dsql/scripts/run_functional_eva --verbose ``` -**What it checks** (22 eval prompts, 93 assertions total): +**What it checks** (27 eval prompts, 118 assertions total): | Eval | Focus | Grader | Key assertions | | ------------------------------ | --------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1. Transaction limits | MCP delegation | regex | Calls `awsknowledge`, cites 3,000 row-modification limit, recommends batching | | 2. Multi-tenant schema | Correctness | LLM judge | Uses non-null tenant keys, tenant-scoped foreign keys, `CREATE INDEX ASYNC`, separate DDL txns | -| 3. Index limits | MCP delegation | regex | Calls `awsknowledge`, cites 24 index limit, suggests alternatives | +| 3. Index limits | MCP delegation | regex | Calls `awsknowledge`, states the 24-index limit without framing it as excluding the PK, suggests alternatives | | 4. Python connection | Language routing | regex | Recommends DSQL Python Connector, IAM auth, 15-min token expiry, SSL | | 5. Column type change | DDL migration routing | LLM judge | Table Recreation Pattern, dependency gate, batching, user confirmation | | 6. JSON column storage | Type guidance | LLM judge | Recommends `JSONB` (or `JSON`) as the column type for queryable structured data | @@ -122,6 +122,11 @@ mise exec -- python tools/evals/databases-on-aws/dsql/scripts/run_functional_eva | 20. Direct constraint changes | Constraint migration | LLM judge | Direct `DROP CONSTRAINT`, CHECK `NOT VALID`, async validation, no table recreation | | 21. Direct column options | Column migration | LLM judge | Direct `DROP NOT NULL` and default changes, no table recreation | | 22. Joined SELECT FOR UPDATE | ORM routing (Django) | LLM judge | Keeps joined/non-key locking query, explains commit-time OCC checks, uses targeted-row primary-key accounting toward the 10 MiB transaction-size limit, covers lock clauses, retries SQLSTATE 40001 | +| 23. Index key sort order | Index conversion | LLM judge | Strips `ASC`/`DESC`, keeps `ASYNC`, explains the backward scan serves a uniform `ORDER BY` but not a mixed one, no counter-based lint gating, retains `NULLS` placement | +| 24. Transaction size exceeded | Troubleshooting | LLM judge | Identifies the 10 MiB limit, checked at `COMMIT` and not against raw bytes, splits the batch, also cites the 3,000-row limit, does not suggest raising it | +| 25. EOF at COMMIT | Troubleshooting | LLM judge | Commit outcome unknown, bare retry can double-debit, reconnect and confirm, addresses the unconfirmable case, not an ordinary `40001` retry | +| 26. Catalog change (OC001) | OCC retry | LLM judge | `GRANT`/`REVOKE` raise `OC001` like DDL, snapshot predates the change rather than overlapping it, retry clears a one-shot, serialize if persistent, stays in the `40001` loop | +| 27. Key size too large | Limits | LLM judge | Budget combined across key columns, ~1,981 bytes for one column, secondary index shares the primary key's budget, bytes not characters, shorten or hash | ### Grader modes @@ -130,7 +135,7 @@ Every eval consumed by `run_functional_evals.py` **MUST** declare one of two gra `"schema_version": 2`; result artifacts report schema and grading-protocol version `2` because the stricter grading and incomplete-run semantics are not directly comparable with older runs. -- **Regex / tool-call**: fast, cheap, deterministic. Each accepted assertion maps to a registered rule; the schema rejects assertions without one. Dedicated rules validate exact tool behavior and safety constraints. Compatibility rules retained for legacy corpora use polarity-aware keyword scoring. Limit rules bind values to their subject, such as 3,000 rows per transaction, 24 indexes per table, and 8 columns per index. +- **Regex / tool-call**: fast, cheap, deterministic. Each accepted assertion maps to a registered rule; the schema rejects assertions without one. Dedicated rules validate exact tool behavior and safety constraints. Compatibility rules retained for legacy corpora use polarity-aware keyword scoring. Limit rules bind values to their subject, such as 3,000 rows per transaction, 24 indexes per table (counting the primary key), and 8 columns per index. The index rule additionally vetoes the common off-by-one — stating the 24 as excluding the primary key — rather than only checking the number is present. - **LLM judge**: runs a tool-free `claude -p` once per expectation with the complete redacted final answer, bounded call and result metadata, a complete tool-name inventory, the user prompt, and the assertion. Answers longer than 18,000 redacted characters are ungraded instead of being truncated. Tool-result bodies are explicitly untrusted and cannot independently satisfy assertions about what the agent presents or explains. The judge returns `{passed, evidence}`. Use it for semantic assertions where paraphrasing, negation, or synonym coverage makes regex brittle, such as "Does NOT recommend X." Each assertion incurs model-dependent cost and latency. Select the judge independently of the subject via `--judge-model` (defaults to the CLI default) diff --git a/tools/evals/databases-on-aws/dsql/evals.json b/tools/evals/databases-on-aws/dsql/evals.json index bfac8e8f..f61f080a 100644 --- a/tools/evals/databases-on-aws/dsql/evals.json +++ b/tools/evals/databases-on-aws/dsql/evals.json @@ -33,10 +33,10 @@ { "id": 3, "prompt": "I need to add 30 indexes to my DSQL table for different query patterns. Is that possible?", - "expected_output": "Explains the 24 index per table limit, verifies via awsknowledge, suggests alternatives like composite indexes or query redesign", + "expected_output": "Explains the 24 index per table limit, counting the primary key, so 23 secondary indexes; verifies via awsknowledge, suggests alternatives like composite indexes or query redesign", "expectations": [ "Calls awsknowledge search_documentation with an index-related query", - "Mentions the 24 indexes per table limit", + "Mentions the 24 indexes per table limit, including the primary key", "Mentions the 8 columns per index limit", "Suggests alternatives such as composite indexes or reducing index count" ], @@ -276,6 +276,71 @@ "Recommends retrying the whole transaction with backoff when a conflict returns SQLSTATE 40001" ], "grader": "llm_judge" + }, + { + "id": 23, + "prompt": "I'm porting this index to Aurora DSQL: CREATE INDEX idx_logs ON logs (tenant_id, created_at DESC); It fails. What do I do, and do I lose the descending ordering?", + "expected_output": "Identifies that DSQL rejects sort direction on index keys, strips ASC/DESC, and explains that a uniformly-descending ORDER BY is still served by a backward scan while a mixed-direction one is not", + "expectations": [ + "States that DSQL rejects ASC/DESC on index keys and that the direction must be removed, keeping CREATE INDEX ASYNC", + "Explains that a uniformly-ordered ORDER BY is still served because the planner can scan the index backwards, so the descending ordering is not lost", + "States that a mixed-direction ORDER BY still requires a sort step that no DSQL index can remove", + "Does not claim that dsql_lint reports the stripped direction as a warning in every case, or advise gating a pipeline solely on the error or warning count", + "Does not advise removing NULLS FIRST or NULLS LAST, which DSQL accepts and dsql_lint retains" + ], + "grader": "llm_judge" + }, + { + "id": 24, + "prompt": "My DSQL batch insert fails with: ERROR: transaction size limit 10mb exceeded, DETAIL: Current transaction size 11mb > 10mb. I'm only inserting 2,000 rows. How do I fix it?", + "expected_output": "Identifies the 10 MiB per-transaction limit, explains the check happens at COMMIT and is not a raw byte count, and recommends splitting the batch", + "expectations": [ + "Identifies the 10 MiB per-transaction size limit as the cause and recommends splitting the write into smaller committed batches", + "States that the limit is checked at COMMIT rather than per statement", + "Notes that the size the limit applies to is not the raw byte count of the data, so the raw size does not reliably predict whether a batch commits", + "Also mentions the separate 3,000 row-modification limit per transaction", + "Does not advise raising the limit, since it is not configurable" + ], + "grader": "llm_judge" + }, + { + "id": 25, + "prompt": "My Python app sent a COMMIT to DSQL and got 'SSL SYSCALL error: EOF detected' instead of a reply. The transaction debits an account balance. Should I just retry it?", + "expected_output": "States that the commit outcome is unknown, that a bare retry can double-apply the write, and that the agent must confirm the outcome or report it rather than blindly retrying", + "expectations": [ + "States that an EOF at or after COMMIT leaves the commit outcome unknown because the connection closed before the server replied", + "Explicitly warns that a bare retry can apply the debit twice", + "Advises reconnecting and confirming whether the write committed before resending", + "Addresses what to do when the outcome cannot be determined, rather than only prescribing confirmation", + "Does not classify the EOF as an ordinary transient error to be fed into the 40001 retry loop" + ], + "grader": "llm_judge" + }, + { + "id": 26, + "prompt": "My DSQL migration only runs GRANT SELECT statements, no DDL. A long-running job that reads a large table and writes summary rows back keeps failing at COMMIT with 'schema has been updated by another transaction (OC001)' and SQLSTATE 40001. There's no DDL anywhere, so why?", + "expected_output": "Explains that GRANT and REVOKE are catalog changes that raise OC001 just as DDL does, that the trigger is a snapshot predating the change rather than concurrency, and that a retry clears a one-shot change", + "expectations": [ + "States that GRANT and REVOKE modify the schema catalog and raise OC001 just as DDL statements do, so the absence of DDL does not rule it out", + "Explains that the condition is the transaction's snapshot predating a catalog change, not two statements overlapping in time", + "States that the reporting transaction should retry, because a retry takes a fresh snapshot and a one-shot catalog change clears", + "Advises serializing the catalog changes if the error persists across retries", + "Does not advise excluding OC001 from the 40001 retry loop or treating it as non-retryable" + ], + "grader": "llm_judge" + }, + { + "id": 27, + "prompt": "In DSQL I get 'key size too large' when inserting into a table whose primary key is (tenant_name text, region text, bucket text). What's the actual budget, and does adding a secondary index on a long text column get its own budget?", + "expected_output": "Explains the combined key-size budget across all key columns, that a single column reaches roughly 1,981 bytes, and that a secondary index shares the budget with the table's primary key columns rather than getting a separate one", + "expectations": [ + "States that the key-size budget is combined in bytes across every key column, not per column", + "Gives the single-column ceiling as roughly 1,981 bytes and notes that each additional key column reduces the budget", + "States that a secondary index shares the budget with the table's primary key columns rather than getting a separate allowance", + "Notes that the budget is in bytes rather than characters, so multi-byte UTF-8 values fit fewer characters", + "Recommends shortening or hashing the key columns rather than attempting to raise the limit" + ], + "grader": "llm_judge" } ] } diff --git a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py index 8b45e2cf..2788f9e4 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py @@ -3194,6 +3194,8 @@ def _build_judge_evidence(run_result: dict) -> str: AssertionRule.TRANSACTION_SIZE_LIMIT, "mentions the 24 indexes per table limit": AssertionRule.INDEXES_PER_TABLE, + "mentions the 24 indexes per table limit, including the primary key": + AssertionRule.INDEXES_PER_TABLE, "mentions the 8 columns per index limit": AssertionRule.COLUMNS_PER_INDEX, "suggests alternatives such as composite indexes or reducing index count": @@ -3332,6 +3334,11 @@ def _build_judge_evidence(run_result: dict) -> str: r"no\s+(?:\w+\s+){0,4}(?:may|can|should|must)\s+exceed)\s*$", re.IGNORECASE, ) +POSITIVE_UPPER_BOUND_SUFFIX = re.compile( + r"^\s*(?:cannot|can't|may\s+not|must\s+not|should\s+not)\s+" + r"(?:\w+\s+){0,4}exceed\b", + re.IGNORECASE, +) POST_MATCH_NEGATION = re.compile( r"^\s*(?:" r"(?:is|are|was|were|does|do|did|should|must|can)\s+" @@ -3396,6 +3403,10 @@ def _match_is_positive(value: str, match: re.Match) -> bool: suffix = value[match.end():match.end() + 80] if POSITIVE_UPPER_BOUND.search(prefix): prefix = "" + # Symmetric with the prefix case: "a table can't exceed 24 indexes" states an + # upper bound, so the trailing "can't" must not negate the preceding match. + if POSITIVE_UPPER_BOUND_SUFFIX.search(suffix): + suffix = "" return ( NEGATED_MENTION.search(prefix) is None and POST_MATCH_NEGATION.search(suffix) is None @@ -4935,18 +4946,163 @@ def grade_eval( evidence = "No mention of 10 MiB data size limit found" # --- Assertion: 24 indexes --- + # The 24 includes the primary key, so a table with a PK accepts 23 + # secondary indexes. Stating the cap as 24 *secondary* is the off-by-one + # this rule exists to catch, so the veto below rejects it both ways round: + # additively ("24 indexes in addition to / plus / besides / beyond / + # on top of the primary key") and subtractively ("24 indexes excluding / + # not counting / apart from / aside from / other than / separate from the + # primary key"). "24 including the primary key" is correct and must still + # pass, which is why the veto lists the excluding phrases rather than + # matching on "primary key" alone. + # + # The veto is a closed list of phrases and is matched per statement, so it + # is best-effort, not exhaustive: a synonym outside the list, or the wrong + # claim split into a separate sentence from the cap, still passes. Extend + # the alternation when a new phrasing is observed, and add a case to + # test_run_functional_evals.py alongside it -- an untested alternative is + # an untested claim. elif rule is AssertionRule.INDEXES_PER_TABLE: - if _has_positive_statement( + subject = r"\btables?\b" + # "cap"/"capped at" is as common as "limit" in answers about this and + # was previously graded as no mention at all. "can have"/"supports" + # and a bare "per table" carry the same force in the table and bullet + # shapes a doc-quoting answer uses, where no bound word appears at all. + bound = ( + r"\b(?:limit|maximum|max|at\s+most|up\s+to|cap(?:ped|s)?|" + r"cannot\s+exceed|can't\s+exceed|no\s+more\s+than|" + r"can\s+have|supports?|allows?|accepts?|permits?|holds?|" + r"per\s+tables?)" + ) + # Singular included: "the 24-index limit" and "max 24 index per + # table" are both ordinary phrasings, and graded as no mention at + # all while this matched only the plural. + index_noun = r"(?:indexes|indices|index)" + # The number and the index noun are often NOT adjacent: a markdown + # table puts a cell boundary between them, a bolded number puts + # asterisks, and many correct answers put the noun first ("the maximum + # number of indexes per table is 24"). Allow either order across a + # short run that contains no other digit -- excluding digits stops + # "24 columns and 8 indexes" from counting as a statement of this cap. + gap = r"[^.!?\n\d]{0,40}?" + secondary_adj = ( + r"(?:secondary|non-?primary(?:[- ]key)?|non-?PK|additional|extra)" + ) + states_cap = _has_positive_statement( text, - r"\b24\s+(?:secondary\s+)?(?:indexes|indices)\b", - r"\btables?\b", - r"\b(?:limit|maximum|max|at\s+most|up\s+to|" - r"cannot\s+exceed|can't\s+exceed|no\s+more\s+than)", - ): + r"(?:" + rf"\b24\b{gap}\b{index_noun}\b" + rf"|\b{index_noun}\b{gap}\b24\b" + rf"|\b23\s+{secondary_adj}\s+{index_noun}\b" + # Noun-first for 23 must keep the "secondary" qualifier: a bare + # "23 indexes per table" is itself wrong, since 23 is the + # secondary count and 24 is the total. + rf"|\b{secondary_adj}\s+{index_noun}\b{gap}\b23\b" + r")", + subject, + bound, + ) + # Phrases that place the primary key OUTSIDE the 24. Matched anywhere + # in the statement rather than adjacent to the number, so a fronted + # clause ("Besides the primary key, a table supports 24 indexes") and + # an em-dashed trailer ("24 indexes per table - plus the primary key") + # are caught alongside the unpunctuated form. + primary_key = r"\b(?:primary\s+keys?|PKs?)\b" + _exclusion_phrase = ( + r"(?:in\s+addition\s+to|plus|besides|beyond|on\s+top\s+of" + r"|excluding|not\s+counting|apart\s+from|aside\s+from" + r"|other\s+than|separate\s+from|exclusive\s+of" + r"|not\s+includ(?:e|es|ing)" + r"|do(?:es)?\s+not\s+(?:include|count)" + r"|do(?:es)?n['’]t\s+(?:include|count))" + r"\s+(?:the\s+|your\s+|its\s+|a\s+|that\s+)?" + ) + # "the primary key does not count toward the limit" states the same + # error with the subject first, and lands in its own sentence often + # enough that requiring the number alongside it would miss it. + _pk_discounted = ( + r"\s+(?:does\s+not|do\s+not|does\s?n['’]t|do\s?n['’]t" + r"|is\s+not|isn['’]t)\s+" + r"(?:\w+\s+){0,3}(?:count|counted|include[ds]?|included)" + ) + excludes_pk = ( + _exclusion_phrase + primary_key + + r"|" + primary_key + _pk_discounted + ) + # The inclusive framing is correct, so it cancels the veto -- but only + # within the statement that triggered it. Cancelling document-wide let + # one correct sentence license a wrong claim anywhere else in the + # answer. The lookbehinds keep "not including the primary key" from + # reading as the inclusive phrasing. + includes_pk = ( + r"(? bool: + if re.search(rf"\b{index_noun}\b", statement, re.IGNORECASE): + return True + return re.search( + other_limit_unit, statement, re.IGNORECASE + ) is None + + claims_24_secondary = ( + claims_24_secondary_outright and not states_23 + ) or any( + _concerns_the_index_cap(statement) + and re.search(excludes_pk, statement, re.IGNORECASE) + and not re.search(includes_pk, statement, re.IGNORECASE) + for statement in re.split(r"(?<=[.!?;])\s+|\n+", text) + ) + if states_cap and not claims_24_secondary: passed = True - evidence = "Found a positive 24-index limit in response" + evidence = "Found a positive 24-index (or 23 secondary) limit" + elif claims_24_secondary: + evidence = ( + "Response states the 24-index cap as excluding the primary " + "key; the 24 includes it, so a table with a PK accepts 23" + ) + elif _has_positive_statement( + text, + rf"\b(?:23|24)\s+(?:\w+(?:[- ]\w+)?\s+)?{index_noun}\b", + subject, + bound, + ): + # Distinguishes "stated a cap in a shape this rule does not + # recognise" from "never mentioned the limit". Collapsing the two + # sends whoever triages a red eval after a missing-doc problem + # when the real defect is a wrong number. + evidence = ( + "Stated an index cap in an unrecognised form; could not " + "confirm whether it counts the primary key" + ) else: - evidence = "No mention of 24 indexes per table limit found" + evidence = ( + "No mention of 24 indexes per table (or 23 secondary) found" + ) # --- Assertion: 8 columns per index --- elif rule is AssertionRule.COLUMNS_PER_INDEX: diff --git a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py index eea56d35..75239ea8 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py @@ -177,6 +177,256 @@ def test_safe_query_grading_rejects_explicit_f_string_injection() -> None: assert safe["summary"]["passed"] == 1 # nosec B101 +def test_indexes_per_table_evidence_distinguishes_wrong_from_absent() -> None: + """The three failure paths must each say *why* they failed. + + Collapsing "stated the cap as excluding the PK" into "never mentioned the + limit" sends whoever triages a red eval after a missing-doc or routing + problem when the real defect is a wrong number. Nothing else in the suite + asserts these strings, so a refactor that merged the branches would stay + green. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + + wrong = RUNNER.grade_eval( + eval_item, + _regex_run_result( + "A table supports at most 24 indexes excluding the primary key." + ), + ) + unrecognised = RUNNER.grade_eval( + eval_item, + _regex_run_result("A table supports at most 23 unique indexes."), + ) + absent = RUNNER.grade_eval( + eval_item, + _regex_run_result("DSQL requires CREATE INDEX ASYNC."), + ) + + assert wrong["summary"]["failed"] == 1 # nosec B101 + assert ( # nosec B101 + "excluding the primary key" in wrong["expectations"][0]["evidence"] + ) + assert unrecognised["summary"]["failed"] == 1 # nosec B101 + assert ( # nosec B101 + "unrecognised form" in unrecognised["expectations"][0]["evidence"] + ) + assert absent["summary"]["failed"] == 1 # nosec B101 + assert "No mention" in absent["expectations"][0]["evidence"] # nosec B101 + + +def test_indexes_per_table_accepts_doc_quoting_shapes() -> None: + """A correct answer must pass however it is laid out. + + Eval 3 requires an `awsknowledge` documentation call, so the answer tends to + arrive as a quoted limits table or bullet list rather than prose. Requiring + the number and the index noun to be adjacent, in that order, in one sentence + failed exactly the shapes the eval induces -- a markdown row puts a cell + boundary between them, a bullet puts the noun first, and a bolded number puts + asterisks. Each entry below was observed to fail before. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + correct = ( + "| Indexes per table | 24 (includes the primary key) |", + "The maximum number of indexes per table is 24, including the " + "primary key.", + "A table can't exceed 24 indexes, including the primary key.", + "The limit is **24** indexes per table, including the primary key.", + "A table can have 24 indexes, including the primary key.", + "Limits:\n- Indexes per table: 24, including the primary key", + "A DSQL table accepts 23 secondary indexes.", + ) + + for text in correct: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["passed"] == 1, text # nosec B101 + + +def test_indexes_per_table_accepts_the_singular_noun() -> None: + """"24-index limit" is a correct answer, not a missing one. + + Matching only the plural graded both forms below as "No mention of 24 + indexes per table found", which is the same defect -- failing a correct + answer over its shape -- that the doc-quoting cases above cover. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + correct = ( + "A DSQL table respects the 24-index limit per table, including the " + "primary key.", + "Max 24 index per table, including the primary key.", + ) + + for text in correct: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["passed"] == 1, text # nosec B101 + + +def test_indexes_per_table_veto_ignores_other_limits() -> None: + """A true "primary key does not count" clause about another limit is not the error. + + The primary key genuinely does not count toward the 1,600 lifetime column + ceiling. Matching that phrase without requiring the statement to concern + indexes made the grader fail a correct answer *and* report it as having + stated the cap wrongly -- the exact off-by-one it exists to police. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + + graded = RUNNER.grade_eval( + eval_item, + _regex_run_result( + "A table supports at most 24 indexes, including the primary key. " + "Separately, the primary key does not count toward the 1,600 " + "lifetime column limit." + ), + ) + + assert graded["summary"]["passed"] == 1 # nosec B101 + + # The bare form, with no other limit named, must still veto. + still_wrong = RUNNER.grade_eval( + eval_item, + _regex_run_result( + "A table supports at most 24 indexes. The primary key does not " + "count toward that limit." + ), + ) + + assert still_wrong["summary"]["failed"] == 1 # nosec B101 + + +def test_indexes_per_table_veto_catches_the_off_by_one_phrasings() -> None: + """Every way of putting the primary key outside the 24 must fail. + + This is the error the rule exists to catch, so a phrasing that slips past it + is worse than a missing assertion: the suite reports the wrong answer as + correct. The inclusive guard that excuses a contrastive "24 secondary" + mention must not excuse these. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + wrong = ( + "A table supports at most 24 indexes, not including the primary key.", + # the inclusive phrase must not license the wrong conclusion drawn + # from it in the same breath + "A table is capped at 24 indexes including the primary key, so you " + "get 24 secondary indexes.", + # the contradiction in a following sentence + "A table supports at most 24 indexes. The primary key does not count " + "toward that limit.", + # fronted exclusion, before the number + "Besides the primary key, each table supports a maximum of 24 indexes.", + # an em dash is a separator like any other + "The limit is 24 indexes per table — plus the primary key.", + "The maximum is 24 indexes per table, excluding primary keys.", + ) + + for text in wrong: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["failed"] == 1, text # nosec B101 + assert ( # nosec B101 + "excluding the primary key" in graded["expectations"][0]["evidence"] + ), text + + +def test_indexes_per_table_keeps_the_contrastive_exemption() -> None: + """An answer may quote the wrong framing in order to correct it. + + Pre-empting the misconception, or contrasting with PostgreSQL, is the most + useful answer a reader can get. The veto is cancelled when the response also + states the 23-secondary consequence positively, which is what those answers + carry and what a bare wrong claim does not. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + correct = ( + "In PostgreSQL you could have 24 secondary indexes, but DSQL's limit " + "of 24 indexes per table includes the primary key, so you get 23 " + "secondary indexes.", + "A common mistake is to assume 24 secondary indexes. The limit is 24 " + "indexes per table including the primary key, leaving 23 secondary " + "indexes.", + ) + + for text in correct: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["passed"] == 1, text # nosec B101 + + +def test_indexes_per_table_ignores_unrelated_counts() -> None: + """Relaxing adjacency must not make any nearby 24 count as the cap. + + The gap between the number and the index noun admits no other digit, so a + sentence that happens to mention 24 of something else alongside indexes is + still "no mention" rather than a stated limit. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + + graded = RUNNER.grade_eval( + eval_item, + _regex_run_result( + "A table supports 24 columns and 8 indexes per index key." + ), + ) + + assert graded["summary"]["failed"] == 1 # nosec B101 + + # 23 is the secondary count, not the total, so a bare "23 indexes per + # table" is wrong and must not satisfy the cap just because the relaxed + # ordering lets the noun precede the number. + bare_23 = RUNNER.grade_eval( + eval_item, + _regex_run_result( + "The maximum number of indexes per table is 23." + ), + ) + + assert bare_23["summary"]["failed"] == 1 # nosec B101 + + +def test_indexes_per_table_alias_shares_the_rule() -> None: + """Both expectation strings must dispatch to the same rule. + + `evals.json` uses the PK-inclusive string; the short one is kept for + out-of-tree corpora. If either stops resolving, `validate_evals_data` + rejects the corpus at load rather than silently skipping the assertion. + """ + short = "Mentions the 24 indexes per table limit" + long = "Mentions the 24 indexes per table limit, including the primary key" + + rules = { + RUNNER.ASSERTION_RULES[RUNNER._normalize_assertion(text)] + for text in (short, long) + } + + assert rules == {RUNNER.AssertionRule.INDEXES_PER_TABLE} # nosec B101 + + def test_separate_ddl_grading_rejects_combined_transaction() -> None: eval_item = { "prompt": "Create a DSQL schema.", @@ -1994,8 +2244,165 @@ def capture_judge_evidence(**kwargs): 1, ), ( + "Mentions the 24 indexes per table limit", + "A table supports at most 23 secondary indexes.", + 1, + ), + ( + # The 24 counts the primary key, so this phrasing is wrong. "Mentions the 24 indexes per table limit", "A table supports at most 24 secondary indexes.", + 0, + ), + ( + # Same error stated alongside the correct cap: a presence-only check + # passes this, which is why the rule vetoes the wrong claim. + "Mentions the 24 indexes per table limit", + "A table has a maximum of 24 indexes, so 24 secondary indexes.", + 0, + ), + ( + # The off-by-one without the word "secondary". + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes in addition to the primary key.", + 0, + ), + ( + # "including" is the correct framing and must not trip the veto. + "Mentions the 24 indexes per table limit", + "The limit is 24 indexes per table including the primary key.", + 1, + ), + ( + "Mentions the 24 indexes per table limit", + "A table has at most 24 total indexes.", + 1, + ), + ( + # The PK-inclusive expectation string is an alias of the same rule; + # evals.json uses it, so it has to keep dispatching. + "Mentions the 24 indexes per table limit, including the primary key", + "The limit is 24 indexes per table including the primary key.", + 1, + ), + ( + "Mentions the 24 indexes per table limit, including the primary key", + "A table supports at most 24 secondary indexes.", + 0, + ), + # One case per excluding phrase the veto enumerates. Each of these states + # the off-by-one and must fail; an untested alternative is an untested + # claim, and a later edit that drops one of them would otherwise stay + # green. + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes plus the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indices besides the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes beyond the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes on top of the primary key.", + 0, + ), + ( + # The subtractive family. "excluding" is the most natural English + # phrasing of this error, so its absence was the widest hole. + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes excluding the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes not counting the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes apart from the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes other than the primary key.", + 0, + ), + ( + # A comma between the noun and the phrase must not rescue the wrong + # claim -- whether the veto fires cannot depend on punctuation. + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes, plus the primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes (in addition to the primary key).", + 0, + ), + ( + # "per table" intervening is the phrasing the rule's own subject + # pattern expects, so it has to be reachable. + "Mentions the 24 indexes per table limit", + "The maximum is 24 indexes per table plus the PK.", + 0, + ), + ( + # A possessive determiner instead of "the". + "Mentions the 24 indexes per table limit", + "A table supports at most 24 indexes in addition to your primary key.", + 0, + ), + ( + "Mentions the 24 indexes per table limit", + "The max is 24 indexes per table, which does not include the " + "primary key.", + 0, + ), + # Correct phrasings that must survive the widened veto. + ( + # Quoting the wrong framing to contrast with PostgreSQL, or to + # pre-empt the misconception, is the most useful answer a reader can + # get. The veto must not punish it. + "Mentions the 24 indexes per table limit", + "In PostgreSQL you can have 24 indexes plus the primary key, but a " + "DSQL table limits you to 24 total indexes including the PK.", + 1, + ), + ( + "Mentions the 24 indexes per table limit", + "The cap is 24 indexes per table including the primary key, so you " + "get 23 secondary indexes.", + 1, + ), + ( + # "cap"/"capped at" is as common as "limit" and used to grade as no + # mention at all. + "Mentions the 24 indexes per table limit", + "Each table is capped at 24 indexes including the primary key.", + 1, + ), + ( + "Mentions the 24 indexes per table limit", + "A table may have at most 23 non-PK indexes.", + 1, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 23 non-primary-key indexes.", + 1, + ), + ( + "Mentions the 24 indexes per table limit", + "A table supports at most 24 combined indices.", 1, ), ( From e34130ff57fa9999799e04833e8341bb145eebb8 Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Wed, 7 Oct 2026 15:53:00 -0700 Subject: [PATCH 2/8] fix(dsql): address review on limits and OC001 guidance OC001 hits reads too, so drop the read-only exemption. The cause is a stale per-session catalog cache, not a transaction snapshot; eval 26 now accepts the documented completed-before-start case. Drop the 10 MiB formula. Per-entry cost depends on index key and PK width, so point agents at the 54000 DETAIL instead. Every index counts toward the 24, UNIQUE included. Key budget is ~1,981 bytes less ~7 per extra column, 1,474 with a 500-byte PK. Add the 2,000-byte DDL-time key check. Zero lint diagnostics is not acceptance; drop the fixed_sql compare. The EOF rule now covers autocommit writes. Fix the capitalisation note and trim SKILL.md to 299 lines. Fix INDEXES_PER_TABLE misgrades in both directions and pin each probe in tests. --- plugins/databases-on-aws/skills/dsql/SKILL.md | 8 +- .../references/auth/authentication-guide.md | 5 +- .../dsql/references/development-guide.md | 2 +- .../skills/dsql/references/dsql-lint.md | 8 +- .../skills/dsql/references/examples/schema.md | 2 +- .../dsql/references/occ-retry-patterns.md | 25 ++- .../pg-migrations/index-conversion.md | 13 +- .../references/pg-migrations/multi-region.md | 3 - .../skills/dsql/references/troubleshooting.md | 106 ++++++----- tools/evals/databases-on-aws/README.md | 2 +- tools/evals/databases-on-aws/dsql/evals.json | 6 +- .../dsql/scripts/run_functional_evals.py | 157 +++++++++++------ .../dsql/scripts/test_run_functional_evals.py | 166 +++++++++++++++++- 13 files changed, 342 insertions(+), 161 deletions(-) diff --git a/plugins/databases-on-aws/skills/dsql/SKILL.md b/plugins/databases-on-aws/skills/dsql/SKILL.md index fbad42c6..927f034b 100644 --- a/plugins/databases-on-aws/skills/dsql/SKILL.md +++ b/plugins/databases-on-aws/skills/dsql/SKILL.md @@ -158,10 +158,6 @@ defaults that may change — when a user's decision depends on an exact limit, v | IDENTITY/SEQUENCE CACHE values | 1 or >= 65536 | `aurora dsql sequence cache` | | Supported column data types | See docs | `aurora dsql supported data types` | -That the 24 index limit counts the primary key — leaving 23 secondary indexes — is observed -behaviour; the quotas page states only the 24, so `awsknowledge` will not confirm the inclusive -framing. - **When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. For the full limit set with each SQLSTATE and its exact error message, see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes). **Fallback:** If `awsknowledge` is unavailable, use the defaults above and flag that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). @@ -291,10 +287,10 @@ MUST load [system-diagnostics/workflow.md](references/system-diagnostics/workflo - **`awsknowledge` returns no results:** Use the default limits in the table above and note that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). - **`dsql_lint` unavailable or timing out:** See the Error Handling section of [dsql-lint.md](references/dsql-lint.md). Do not silently skip validation — inform the user and require explicit confirmation before proceeding with manual rules from [development-guide.md](references/development-guide.md). -- **OCC serialization error:** Retry the transaction. If persistent, check for hot-key contention. If the message contains `(OC001)` the cause is a schema catalog change — any DDL, and also `GRANT`/`REVOKE` — not row contention. A one-shot change clears on the first retry; if it persists, another session is still changing the catalog and you **MUST** serialize those changes. Match `(OC001)` as a substring, not as a suffix: some drivers append their own text such as `(SQLSTATE 40001)`. See [troubleshooting.md](references/troubleshooting.md). +- **OCC serialization error:** Retry the transaction. If persistent, check for hot-key contention. If the message contains `(OC001)` the session's cached schema catalog is older than a committed catalog change — any DDL, and also `GRANT`/`REVOKE`, even one that committed before the transaction began — not row contention or overlapping timing. Reads are hit too. A retry refreshes the cache, so a one-shot change clears on the first retry; if it persists, another session is still changing the catalog and you **MUST** serialize those changes. Match `(OC001)` as a substring, not as a suffix: some drivers append their own text such as `(SQLSTATE 40001)`. See [troubleshooting.md](references/troubleshooting.md). - **Foreign key violation (`23503`):** Correct the relationship or referential action; **MUST NOT** send it through the `40001` retry loop — see [troubleshooting.md](references/troubleshooting.md). -- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB; locked-row primary keys count toward the size limit. The 10 MiB check runs at `COMMIT` against a size that exceeds the raw bytes by ~150 bytes per row plus ~430 bytes per secondary index entry, so an indexed table has a much smaller usable budget — see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes) for the formula, and [batched-migration.md](references/ddl-migrations/batched-migration.md) for the batching pattern. +- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB; locked-row primary keys count toward the size limit. The 10 MiB check runs at `COMMIT`, and secondary index entries count toward it, so an indexed table has a smaller usable budget — see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes) for how to size it, and [batched-migration.md](references/ddl-migrations/batched-migration.md) for the batching pattern. - **Token expiration mid-operation:** Generate a fresh IAM token — see [authentication-guide.md](references/auth/authentication-guide.md). See [troubleshooting.md](references/troubleshooting.md) for other issues. ## Additional Resources diff --git a/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md b/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md index e35d9f6c..7cbb7864 100644 --- a/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md +++ b/plugins/databases-on-aws/skills/dsql/references/auth/authentication-guide.md @@ -45,10 +45,7 @@ Part of [DSQL Development Guide](../development-guide.md). **Best practices:** - Keep authentication tokens in memory only; discard after use -- Regenerate the token only when the error indicates a stale one — `unable to accept connection, - access denied` carrying a `Signature expired` hint. The same message without that hint means wrong - credentials, region, or IAM permissions, and a fresh token will not help. See - [troubleshooting.md](../troubleshooting.md#connection-and-authorization) +- Regenerate the token only on a `Signature expired` hint; otherwise a fresh token will not help — see [troubleshooting.md](../troubleshooting.md#connection-and-authorization) - Monitor token generation failures - Set connection timeouts appropriately diff --git a/plugins/databases-on-aws/skills/dsql/references/development-guide.md b/plugins/databases-on-aws/skills/dsql/references/development-guide.md index 1f64b225..c1221da7 100644 --- a/plugins/databases-on-aws/skills/dsql/references/development-guide.md +++ b/plugins/databases-on-aws/skills/dsql/references/development-guide.md @@ -72,7 +72,7 @@ effortless scaling, multi-region viability, among other advantages. - REQUIRED: **at most one DDL statement** per operation - ALWAYS separate schema (DDL) and data (DML) changes - MUST use **`CREATE INDEX ASYNC`:** No synchronous creation (verify limits via `awsknowledge`: `aurora dsql index limits`) - - MAXIMUM: **24 indexes per table, counting the primary key** — so 23 secondary indexes. The quotas page states only the 24; that it counts the primary key is observed behaviour. Indexes still building (`indisvalid = f`) occupy a slot; `DROP INDEX` frees one + - MAXIMUM: **24 indexes per table**, counting every index — the primary key, `UNIQUE` constraints, and ones still building ([details](troubleshooting.md#exceeded-limits)) - MAXIMUM: **8 columns per index** - **MUST** verify index is ready before relying on it: `SELECT indisvalid FROM pg_index WHERE indexrelid = 'index_name'::regclass` — queries work but skip the index until `indisvalid = true` - MUST use **`ALTER TABLE ASYNC ... VALIDATE CONSTRAINT`** for constraint validation: No synchronous validation diff --git a/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md b/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md index 6fad3e90..afdc423e 100644 --- a/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md +++ b/plugins/databases-on-aws/skills/dsql/references/dsql-lint.md @@ -53,8 +53,12 @@ Concrete example (from `dsql_lint(sql="CREATE INDEX idx ON t (c);", fix=true)`): - **Do not gate a pipeline on any one counter.** Severity is derived from `fix_result.status`, so the same rule can land in different counters depending on the input: `index_sort_direction` reports `fixed_with_warning` for `DESC` but a silent `fixed` for `ASC`, even though DSQL rejects both. A - check on `errors == 0` or `warnings == 0` passes SQL that was rewritten underneath it. Gate on - `len(diagnostics) == 0`, or compare `fixed_sql` against the input. + check on `errors == 0` or `warnings == 0` passes SQL that was rewritten underneath it. Check + `len(diagnostics)` instead — but zero means no lint rule fired, not that DSQL accepts the SQL: + operator classes pass with no diagnostic and are rejected + ([index-conversion.md](pg-migrations/index-conversion.md)), while valid `BEGIN ATOMIC` bodies + raise a `parse_error` (see [Error Handling](#error-handling)). Do not compare `fixed_sql` to + the input; it can match while an `unfixable` diagnostic is present. - `statement_preview` is the linter's pointer to the offending statement — useful when presenting diagnostics to the user. --- diff --git a/plugins/databases-on-aws/skills/dsql/references/examples/schema.md b/plugins/databases-on-aws/skills/dsql/references/examples/schema.md index df6e779f..15643158 100644 --- a/plugins/databases-on-aws/skills/dsql/references/examples/schema.md +++ b/plugins/databases-on-aws/skills/dsql/references/examples/schema.md @@ -32,7 +32,7 @@ Both `JSONB` and `JSON` are valid; pick by access pattern (see Schema Design Rul ## Schema Design: Index Creation -MUST use `CREATE INDEX ASYNC` (max 24 indexes/table **including the primary key**, so 23 secondary; 8 columns/index — verify via `awsknowledge`: `aurora dsql index limits`). Source: [aurora-dsql-samples/java/liquibase](https://github.com/aws-samples/aurora-dsql-samples/tree/main/java/liquibase) +MUST use `CREATE INDEX ASYNC` (max 24 indexes/table including the primary key, 8 columns/index — verify via `awsknowledge`: `aurora dsql index limits`). Source: [aurora-dsql-samples/java/liquibase](https://github.com/aws-samples/aurora-dsql-samples/tree/main/java/liquibase) ```sql CREATE INDEX ASYNC idx_owner_city ON owner(city); diff --git a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md index 29214140..1e9b879f 100644 --- a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md +++ b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md @@ -31,28 +31,25 @@ Non-retryable: all other errors, including foreign key violation 23503 (raise im ``` `40001` carries two causes, distinguished by the trailing marker in the message. `(OC000)` is a -write conflict and backoff clears it. `(OC001)` means the transaction's snapshot predates a change -to the schema catalog. +write conflict and backoff clears it. `(OC001)` means the session's cached schema catalog is older +than a catalog change another session committed. **The trigger is any catalog change, not only `CREATE`/`ALTER`/`DROP`, and not only a concurrent one.** `GRANT` and `REVOKE` raise `OC001` just as `ALTER TABLE` and `CREATE INDEX ASYNC` do — DSQL groups them with DDL itself, rejecting a pair with `multiple ddl statements not supported in a -transaction` — and it fires whether the change is still in flight or already finished. What matters -is that the session pinned its snapshot before the change, not that anything overlapped in time. A -migration that only adjusts privileges will raise it, so do not go looking for a `CREATE` or -`ALTER` that is not there. +transaction` — and it fires whether the change is still in flight or already finished. A migration +that only adjusts privileges will raise it, so do not go looking for a `CREATE` or `ALTER` that is +not there. Reads are hit as well as writes, so read paths need the retry loop too. The [DDL and distributed transactions](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-ddl.html) -guide states the completed-change case directly: "An OC001 response can also occur when the schema -change has already completed before the affected transaction starts… On retry, the session refreshes -its catalog cache and the transaction typically succeeds." +guide states the mechanism directly: "An OC001 response can also occur when the schema change has +already completed before the affected transaction starts. Aurora DSQL query processors discover +catalog changes reactively during query execution, so a session that has been idle might still be +operating with a stale catalog version. On retry, the session refreshes its catalog cache and the +transaction typically succeeds." -**Only transactions that write are affected.** A transaction that reads and commits without writing -is never hit, whether the read is in an implicit transaction or an explicit `BEGIN READ ONLY`, so a -read-only reporting workload needs no `OC001` handling at all. - -Because a retry takes a fresh snapshot, a one-shot catalog change clears on the first retry. It +Because a retry refreshes the catalog cache, a one-shot catalog change clears on the first retry. It keeps recurring only while another session keeps issuing changes — serialize those rather than relying on retries. Both markers **MUST** stay in the retry loop: excluding `OC001` turns a one-shot collision that the first retry would have cleared into a hard application error. Capture diff --git a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md index 42cd200e..33970455 100644 --- a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md +++ b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md @@ -251,10 +251,10 @@ to be IMMUTABLE anyway. | Max columns per index | 8 | `54011` | `more than 8 column keys in an index are not supported` | | Max PK/index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | -The primary key occupies one of the 24 slots, so a table with a PK accepts 23 secondary indexes. -Indexes still building (`indisvalid = f`) occupy a slot too, so that budget is spent at submission -and waiting does not recover it. For the key-size derivation, the multi-column budget and the rest -of the limit set, see [troubleshooting.md](../troubleshooting.md#limits-and-error-codes). +Every index takes one of the 24 slots: the primary key, each `UNIQUE` constraint, and indexes +still building (`indisvalid = f`), so a table with only a PK accepts 23 secondary indexes. For the +key-size budget and the rest of the limit set, see +[troubleshooting.md](../troubleshooting.md#limits-and-error-codes). **Strategy when approaching 24 index limit:** @@ -279,8 +279,9 @@ direction you wrote**: So a pipeline that gates on `errors == 0`, or that consumes only `fixed_sql`, proceeds without surfacing that the statement changed — and for `ASC` even a stricter pipeline gating on -`warnings == 0` proceeds, because `ASC` raises no warning at all. Gate on -`len(diagnostics) == 0`, or compare `fixed_sql` against the input, rather than on either counter. +`warnings == 0` proceeds, because `ASC` raises no warning at all. Review every diagnostic rather +than either counter, and see [dsql-lint.md](../dsql-lint.md) for what zero diagnostics does and +does not mean. ```sql -- PostgreSQL diff --git a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md index 18c3843b..48d79bd0 100644 --- a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md +++ b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/multi-region.md @@ -69,9 +69,6 @@ contention across regions — partition data by geography where possible. ## Quotas -Full limit set, with the SQLSTATE and exact message for each database limit: -[troubleshooting.md](../troubleshooting.md#limits-and-error-codes). - | Quota | Value | | --------------------------------------------- | ----------------------------------------------------------------------------------------- | | Multi-region clusters per account, per Region | 5 (increasable) | diff --git a/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md b/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md index 8b0e9604..0821e7ec 100644 --- a/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md +++ b/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md @@ -24,10 +24,12 @@ is a lookup rule for these tables, not a retry rule — classification elsewhere SQLSTATE-based, and `40001` remains the only retryable code (see [occ-retry-patterns.md](occ-retry-patterns.md)). -**MUST** match a stable prefix, not the whole string. ``, `` and `...` in the Message -column are values DSQL interpolates at run time, so an equality test against the cell never -matches. If no message matches, **SHOULD** fall back to the SQLSTATE class and verify the current -limit via `awsknowledge` — message text is not a stable contract. +**MUST** match a stable prefix, not the whole string, and match it case-insensitively — case varies +even within one family (`Datatype limit …` for `char`/`varchar`, `datatype limit …` for `text`). +``, `` and `...` in the Message column are values DSQL interpolates at run time, so an +equality test against the cell never matches. If no message matches, **SHOULD** fall back to the +SQLSTATE class and verify the current limit via `awsknowledge` — message text is not a stable +contract. Values marked **observed** were reproduced against a single-Region cluster on 2026-10-01 (PostgreSQL 16 compatibility). Where an observed value contradicts the @@ -47,8 +49,9 @@ _limits_, which are only observable at run time. | Query temp space | 128 MiB; docs give `53200` and other text | `54000` | `query requires more than allowed temp space of 131072 KB to execute` | | Extended statistics per table | 5 | `54000` | `more than 5 extended statistics per table are not allowed` | | Schemas per database | 10, excluding `public` and `sys` | `54000` | `more than 10 schemas not allowed` | -| Indexes per table | 24 **including the primary key** | `54000` | `more than 24 indexes per table are not allowed` | +| Indexes per table | 24, counting every index — see below | `54000` | `more than 24 indexes per table are not allowed` | | Primary key or index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | +| Declared key size | 2,000 bytes, rejected at DDL time | `54000` | `key size greater than 2000 bytes not supported` | | Row size | 2 MiB | `54000` | `maximum row size exceeded` | | Non-index column size | 1 MiB; docs give the message below, a per-datatype one was seen | `54000` | docs: `maximum column size exceeded`; observed: `datatype limit greater than 1048576 bytes not supported for ` | | View definition size | 131–524 KiB of SQL text observed, varies by shape; docs say 2 MiB | `54000` | `view definition too large` | @@ -71,32 +74,24 @@ Several of these behave in ways the limit alone does not convey: rejected — nothing fails early. Compressible values go much further: a single `text` column accepted 214 MiB of one repeated character. Size against raw byte count; compression only ever buys headroom. -- **The 10 MiB transaction limit counts more than the payload bytes.** The check runs at `COMMIT` - against an accounted size that adds a fixed cost per row, plus a further cost for every - secondary index entry the write produces: - - ```text - accounted ~= raw + rows x (~150 bytes + ~430 bytes per secondary index) - ``` - - Observed with 500 rows and the payload in an unindexed column: 9.93 MiB of payload committed - with no secondary index, 9.73 MiB with one, 9.52 MiB with two, and 9.11 MiB with four — a - straight line at ~429 bytes per index entry. The formula predicted the ceiling at 200, 1,500 - and 2,900 rows to within 1%. **Indexes dominate at scale:** a 3,000-row batch on a table with - four secondary indexes loses ~5 MiB of its budget to index writes before any payload is - counted, so the same statement that commits against a bare table fails against an indexed one. - Note the contrast with the row limit, which the documentation says applies "regardless of the - number of secondary indexes" — that independence does **not** hold for the size limit, and - neither the per-row nor the per-index cost is documented. Budget from the formula rather than - the raw byte count; the accounted size is always above raw, never below, so headroom computed - this way is safe. -- **The key-size budget is combined, in bytes, across every key column.** One column reaches - ~1,981 bytes; each additional key column costs ~8 more, so an 8-column key gets ~241 bytes - each. Bytes, not characters — a 4-byte UTF-8 character key holds only ~495 characters. For a - secondary index the table's primary key columns count against the same budget: the combined - ceiling measures ~1,955 bytes once a second key column exists, so a 500-byte primary key leaves - ~1,455 bytes for the index columns. The quotas page lists the primary key and secondary index as - two separate 1 KiB budgets; the observed behaviour is a single shared budget. +- **The 10 MiB transaction limit also counts index writes.** The check runs at `COMMIT` against an + accounted size that adds a cost per row and per secondary index entry written. The per-entry + cost grows with the index key plus the primary key: small for `int`, `uuid` or `timestamptz` + keys, several times the key width for wide `text` keys. A 500-row batch committed at most + 9.93 MiB of raw data with no secondary index, and 9.11 MiB with four on 100-byte `text` keys. + The row limit is documented as independent of secondary indexes; the size limit is not, and its + accounting is undocumented. Large compressible values, such as JSON over a few KB, can count + below their raw size. To size a batch, read the error: `DETAIL: Current transaction size mb > + 10mb` is the exact accounted size, so one over-limit trial on the real schema gives the ratio to + scale by. +- **The key-size budget is shared, in bytes, by every key column.** One column holds ~1,981 bytes + and each additional key column costs ~7 more, so an 8-column key gets ~241 bytes each. Bytes, + not characters — a 4-byte UTF-8 character key holds ~495 characters. A secondary index key also + carries the table's primary key columns, so a 500-byte primary key leaves ~1,474 bytes for the + index columns. Separately, DDL rejects a key whose _declared_ sizes sum past 2,000 bytes, leaving + the primary key out of that sum for a secondary index; a `varchar(2000)` key therefore passes + `CREATE` and still fails on write above ~1,981 bytes. The quotas page lists the primary key and + secondary index as two separate 1 KiB budgets; the observed behaviour is a single shared budget. - **The view-definition limit is a 512 KiB internal budget, not a text length.** How much SQL fits depends on structure: a target list of literals reached ~524 KiB of definition text, an `IN` list ~229 KiB, a `CASE` chain ~166 KiB, and a chain of OR-ed comparisons only ~131 KiB. The @@ -104,14 +99,17 @@ Several of these behave in ways the limit alone does not convey: OR-chain was rejected at ~127 KiB. Treat **~120 KiB** as a working ceiling, but size by trial rather than by character count when a view approaches it. Deeply nested expressions hit `54001` first. -- **Indexes still building count.** An index with `indisvalid = f` occupies one of the 24 slots, - so the budget is spent at submission and waiting does not recover it. `DROP INDEX` frees a slot. +- **Every index counts toward the 24**: the primary key, the index behind each `UNIQUE` + constraint, and every `CREATE [UNIQUE] INDEX ASYNC`, including one still building + (`indisvalid = f`). A table with a primary key and one `UNIQUE` constraint has 22 slots left; + a table with no primary key has no hidden index and holds 24. Count with + `SELECT count(*) FROM pg_index WHERE indrelid = 'tbl'::regclass`. `DROP INDEX` frees a slot. - **`char` and `varchar` limits are enforced on the _declared_ size at DDL time**, so an oversized declaration fails at `CREATE TABLE`, not on first write. - **Dropped columns keep counting.** The documented ceiling is 1,600 _cumulative_ columns including dropped ones, so a table churned by repeated `ADD COLUMN`/`DROP COLUMN` during a migration can hit `54011` while holding far fewer live columns. Recreate the table rather than continuing to - churn it. + churn it — see [ddl-migrations/overview.md](ddl-migrations/overview.md). ### Rejections and Constraint Violations @@ -155,7 +153,8 @@ These mislead in practice: observed here. If you _do_ see `08P01: invalid message length`, that is this case and the behaviour has changed — trust the error. -**Any EOF at or after `COMMIT` leaves the commit outcome unknown**, whatever the cause, because the +**Any EOF on a statement that could have committed — `COMMIT`, or any write in autocommit mode — +leaves the commit outcome unknown**, whatever the cause, because the connection closed before the server replied. This applies to the oversized-message case above and equally to an EOF the agent classifies as a network fault — the size discriminator tells you why the connection dropped, not whether the write landed. Before resending, an agent **MUST** reconnect and @@ -173,7 +172,8 @@ correlation id the transaction itself records, so the question becomes answerabl The threshold is the _protocol message_ size, which a client does not see directly: a batched or parameterised multi-row insert can exceed it well below 10 MiB of visible data. If you cannot -measure it, reduce the batch size until the EOF stops. +measure it, reduce the batch size until the EOF stops — after confirming, as above, that the +previous attempt did not commit. ### Cluster Quotas @@ -195,10 +195,7 @@ exercise, so treat the values as documented rather than observed. | CDC streams per cluster | 5 | API | `You have reached the stream limit.` | The cluster-count and CDC rows surface as the control-plane API error -`ServiceQuotaExceededException: 402`, not a SQLSTATE, so they never reach a SQL client. The three -`creating more than ...` messages are documented but were not exercised here; note they are the only -rows in this section whose capitalisation differs from the observed ones, which matters if you are -matching case-sensitively. +`ServiceQuotaExceededException: 402`, not a SQLSTATE, so they never reach a SQL client. ## Connection and Authorization @@ -359,17 +356,16 @@ CREATE INDEX ASYNC idx_logs_created ON logs (created_at); 3. Add WHERE clause to limit scope Treat that chunk size as a starting point, not a guarantee. The same transaction is also bounded by -size and age — see [Exceeded Limits](#exceeded-limits) — and the size limit is checked at `COMMIT` -against an accounted size that runs above the raw byte count by roughly 150 bytes per row, plus -~430 bytes for every secondary index entry written. On a table carrying several secondary indexes -that overhead decides the chunk size, so compute the budget from the row width and the index count -rather than from the payload alone. +size and age, and every secondary index entry written counts toward the size. On a table carrying +several secondary indexes that overhead decides the chunk size; see +[Exceeded Limits](#exceeded-limits) for how to size it. ### Error: "schema has been updated by another transaction (OC001)" -**Cause:** The transaction's snapshot predates a change to the schema catalog. **One** change is -enough, and it does **not** have to be concurrent — a change that committed before the transaction -reached its next statement still raises it. +**Cause:** The session's cached copy of the schema catalog is older than a catalog change another +session committed. **One** change is enough, and it does **not** have to be concurrent — a change +that committed before the transaction began still raises it, on the session's next statement +against the changed table. **`OC001` is SQLSTATE `40001`** — the same code as an OCC write conflict, and the same code `SKILL.md` routes into the retry loop. The two causes are distinguished only by the trailing @@ -386,19 +382,17 @@ it out — though note DSQL itself counts `GRANT` and `REVOKE` as DDL when holdi single DDL statement, rejecting a pair with `0A000: multiple ddl statements not supported in a transaction`. -**Only transactions that write are affected.** A transaction that reads and commits without writing -is never hit, whether the read is in an implicit transaction or an explicit `BEGIN READ ONLY`, so a -read-only reporting workload needs no `OC001` handling at all. +Reads are hit as well as writes, including inside `BEGIN READ ONLY`, so read paths need the same +retry loop. The error arrives on the statement when the change committed before it, and at `COMMIT` +when the change lands mid-transaction. Both markers are retryable. A one-shot catalog change clears on the **first** retry, because the -retry takes a fresh snapshot; it keeps recurring only while another session keeps changing the -catalog, and then serializing those changes is the fix rather than more backoff. Note the conflict -is reported at `COMMIT`, and to the transaction that committed **second**, so the failing session -is not necessarily the one that started later. +retry refreshes the session's catalog cache; it keeps recurring only while another session keeps +changing the catalog, and then serializing those changes is the fix rather than more backoff. **Solution:** -1. Retry the transaction — a fresh snapshot is usually all it needs +1. Retry the transaction — refreshing the catalog cache is usually all it needs 2. Apply the same exponential backoff as any other `40001` 3. If it recurs across retries, serialize the catalog changes rather than widening the backoff diff --git a/tools/evals/databases-on-aws/README.md b/tools/evals/databases-on-aws/README.md index 0c04caa4..7657ebbe 100644 --- a/tools/evals/databases-on-aws/README.md +++ b/tools/evals/databases-on-aws/README.md @@ -125,7 +125,7 @@ mise exec -- python tools/evals/databases-on-aws/dsql/scripts/run_functional_eva | 23. Index key sort order | Index conversion | LLM judge | Strips `ASC`/`DESC`, keeps `ASYNC`, explains the backward scan serves a uniform `ORDER BY` but not a mixed one, no counter-based lint gating, retains `NULLS` placement | | 24. Transaction size exceeded | Troubleshooting | LLM judge | Identifies the 10 MiB limit, checked at `COMMIT` and not against raw bytes, splits the batch, also cites the 3,000-row limit, does not suggest raising it | | 25. EOF at COMMIT | Troubleshooting | LLM judge | Commit outcome unknown, bare retry can double-debit, reconnect and confirm, addresses the unconfirmable case, not an ordinary `40001` retry | -| 26. Catalog change (OC001) | OCC retry | LLM judge | `GRANT`/`REVOKE` raise `OC001` like DDL, snapshot predates the change rather than overlapping it, retry clears a one-shot, serialize if persistent, stays in the `40001` loop | +| 26. Catalog change (OC001) | OCC retry | LLM judge | `GRANT`/`REVOKE` raise `OC001` like DDL, the session's cached catalog is stale rather than overlapping the change, retry clears a one-shot, serialize if persistent, stays in the `40001` loop | | 27. Key size too large | Limits | LLM judge | Budget combined across key columns, ~1,981 bytes for one column, secondary index shares the primary key's budget, bytes not characters, shorten or hash | ### Grader modes diff --git a/tools/evals/databases-on-aws/dsql/evals.json b/tools/evals/databases-on-aws/dsql/evals.json index f61f080a..19837feb 100644 --- a/tools/evals/databases-on-aws/dsql/evals.json +++ b/tools/evals/databases-on-aws/dsql/evals.json @@ -319,11 +319,11 @@ { "id": 26, "prompt": "My DSQL migration only runs GRANT SELECT statements, no DDL. A long-running job that reads a large table and writes summary rows back keeps failing at COMMIT with 'schema has been updated by another transaction (OC001)' and SQLSTATE 40001. There's no DDL anywhere, so why?", - "expected_output": "Explains that GRANT and REVOKE are catalog changes that raise OC001 just as DDL does, that the trigger is a snapshot predating the change rather than concurrency, and that a retry clears a one-shot change", + "expected_output": "Explains that GRANT and REVOKE are catalog changes that raise OC001 just as DDL does, that the trigger is the session's stale cached catalog rather than concurrency, and that a retry clears a one-shot change", "expectations": [ "States that GRANT and REVOKE modify the schema catalog and raise OC001 just as DDL statements do, so the absence of DDL does not rule it out", - "Explains that the condition is the transaction's snapshot predating a catalog change, not two statements overlapping in time", - "States that the reporting transaction should retry, because a retry takes a fresh snapshot and a one-shot catalog change clears", + "Explains that the cause is the session's cached schema catalog being older than a committed catalog change, which raises OC001 even when the change completed before the transaction started, not two statements overlapping in time", + "States that the reporting transaction should retry, because a retry refreshes the session's catalog cache and a one-shot catalog change clears", "Advises serializing the catalog changes if the error persists across retries", "Does not advise excluding OC001 from the 40001 retry loop or treating it as non-retryable" ], diff --git a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py index 2788f9e4..d77fb4ef 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py @@ -3325,7 +3325,9 @@ def _build_judge_evidence(run_result: dict) -> str: r"\b(?:do|does|did|is|are|was|were|should|must|can)\s+not|" r"\b(?:don't|doesn't|didn't|isn't|aren't|wasn't|weren't|" r"shouldn't|mustn't)\b" - r")\s+(?:[a-z0-9_-]+\s+){0,12}$", + # Chained words must start alphanumeric, so the dash in "No - DSQL + # supports 24" ends the chain like a comma or an em dash does. + r")\s+(?:[a-z0-9][a-z0-9_-]*\s+){0,12}$", re.IGNORECASE, ) POSITIVE_UPPER_BOUND = re.compile( @@ -4946,22 +4948,12 @@ def grade_eval( evidence = "No mention of 10 MiB data size limit found" # --- Assertion: 24 indexes --- - # The 24 includes the primary key, so a table with a PK accepts 23 - # secondary indexes. Stating the cap as 24 *secondary* is the off-by-one - # this rule exists to catch, so the veto below rejects it both ways round: - # additively ("24 indexes in addition to / plus / besides / beyond / - # on top of the primary key") and subtractively ("24 indexes excluding / - # not counting / apart from / aside from / other than / separate from the - # primary key"). "24 including the primary key" is correct and must still - # pass, which is why the veto lists the excluding phrases rather than - # matching on "primary key" alone. - # - # The veto is a closed list of phrases and is matched per statement, so it - # is best-effort, not exhaustive: a synonym outside the list, or the wrong - # claim split into a separate sentence from the cap, still passes. Extend - # the alternation when a new phrasing is observed, and add a case to - # test_run_functional_evals.py alongside it -- an untested alternative is - # an untested claim. + # The 24 counts every index, the primary key's included, so a table with + # a PK takes 23 secondary indexes. The veto below fails the off-by-one: + # 24 stated as secondary, or the PK placed outside the 24 ("plus", + # "excluding", "does not count"). It is a closed list matched per + # statement, so extend it when a new phrasing turns up, and add a case + # to test_run_functional_evals.py alongside it. elif rule is AssertionRule.INDEXES_PER_TABLE: subject = r"\btables?\b" # "cap"/"capped at" is as common as "limit" in answers about this and @@ -4970,37 +4962,50 @@ def grade_eval( # shapes a doc-quoting answer uses, where no bound word appears at all. bound = ( r"\b(?:limit|maximum|max|at\s+most|up\s+to|cap(?:ped|s)?|" - r"cannot\s+exceed|can't\s+exceed|no\s+more\s+than|" + r"no\s+more\s+than|" r"can\s+have|supports?|allows?|accepts?|permits?|holds?|" r"per\s+tables?)" ) + # "cannot have more than 24" and "doesn't allow more than 24" are + # upper bounds, but the shared negation check reads them as denials. + # Rewrite them, with either apostrophe, to "at most" before matching. + capped_text = re.sub( + r"\b(?:cannot|can\s+not|(?:do|does|may|must|will)\s+not" + r"|(?:ca|do|does|must|wo)n['’]t)\s+" + r"(?:exceed|(?:\w+\s+)?more\s+than)\b", + "at most", + text, + flags=re.IGNORECASE, + ) # Singular included: "the 24-index limit" and "max 24 index per # table" are both ordinary phrasings, and graded as no mention at # all while this matched only the plural. index_noun = r"(?:indexes|indices|index)" - # The number and the index noun are often NOT adjacent: a markdown - # table puts a cell boundary between them, a bolded number puts - # asterisks, and many correct answers put the noun first ("the maximum - # number of indexes per table is 24"). Allow either order across a - # short run that contains no other digit -- excluding digits stops - # "24 columns and 8 indexes" from counting as a statement of this cap. + # A number counts the noun right after it, so number-first allows + # only markup and one modifier ("**24** indexes", "24-index", + # "24 secondary indexes"), which keeps "24 columns per index" out. + # Noun-first ("indexes per table: 24") crosses a short run with no + # other digit, which keeps "24 columns and 8 indexes" out. gap = r"[^.!?\n\d]{0,40}?" secondary_adj = ( - r"(?:secondary|non-?primary(?:[- ]key)?|non-?PK|additional|extra)" - ) - states_cap = _has_positive_statement( - text, - r"(?:" - rf"\b24\b{gap}\b{index_noun}\b" - rf"|\b{index_noun}\b{gap}\b24\b" - rf"|\b23\s+{secondary_adj}\s+{index_noun}\b" + r"(?:secondary|non-?primary(?:[- ]key)?|non-?PK|additional|extra" + r"|user(?:-defined)?)" + ) + # Each shape is matched on its own: in one alternation a negated + # noun-first match ("no fixed index limit, ... 24") consumes the + # positive number-first one after it. + cap_shapes = ( + rf"\b24\b[^\w.!?\n]{{0,4}}(?:[\w-]+\s+)?{index_noun}\b", + rf"\b{index_noun}\b{gap}\b24\b", + rf"\b23\s+{secondary_adj}\s+{index_noun}\b", # Noun-first for 23 must keep the "secondary" qualifier: a bare # "23 indexes per table" is itself wrong, since 23 is the # secondary count and 24 is the total. - rf"|\b{secondary_adj}\s+{index_noun}\b{gap}\b23\b" - r")", - subject, - bound, + rf"\b{secondary_adj}\s+{index_noun}\b{gap}\b23\b", + ) + states_cap = any( + _has_positive_statement(capped_text, shape, subject, bound) + for shape in cap_shapes ) # Phrases that place the primary key OUTSIDE the 24. Matched anywhere # in the statement rather than adjacent to the number, so a fronted @@ -5021,35 +5026,53 @@ def grade_eval( # error with the subject first, and lands in its own sentence often # enough that requiring the number alongside it would miss it. _pk_discounted = ( - r"\s+(?:does\s+not|do\s+not|does\s?n['’]t|do\s?n['’]t" + r"(?:\s+index)?\s+(?:" + r"(?:does\s+not|do\s+not|does\s?n['’]t|do\s?n['’]t" r"|is\s+not|isn['’]t)\s+" r"(?:\w+\s+){0,3}(?:count|counted|include[ds]?|included)" + r"|(?:is|are)\s+(?:excluded|separate)\b)" ) excludes_pk = ( _exclusion_phrase + primary_key + r"|" + primary_key + _pk_discounted ) # The inclusive framing is correct, so it cancels the veto -- but only - # within the statement that triggered it. Cancelling document-wide let - # one correct sentence license a wrong claim anywhere else in the - # answer. The lookbehinds keep "not including the primary key" from - # reading as the inclusive phrasing. + # within the statement that triggered it, or the one before it when + # that statement carries no number of its own. Cancelling + # document-wide let one correct sentence license a wrong claim + # anywhere else in the answer. The lookbehinds keep "not including + # the primary key" from reading as the inclusive phrasing. includes_pk = ( r"(? bool: other_limit_unit, statement, re.IGNORECASE ) is None - claims_24_secondary = ( - claims_24_secondary_outright and not states_23 - ) or any( - _concerns_the_index_cap(statement) - and re.search(excludes_pk, statement, re.IGNORECASE) - and not re.search(includes_pk, statement, re.IGNORECASE) - for statement in re.split(r"(?<=[.!?;])\s+|\n+", text) + def _misplaces_pk(statement: str, previous: str) -> bool: + if not _concerns_the_index_cap(statement): + return False + if ( + re.search( + claims_24_secondary_outright, statement, re.IGNORECASE + ) + and not re.search(no_pk_caveat, statement, re.IGNORECASE) + and not corrects_24_secondary + ): + return True + if not re.search(excludes_pk, statement, re.IGNORECASE): + return False + # "23 secondary indexes plus the primary key" is the right sum. + if re.search(r"\b23\b", statement) and not re.search( + r"\b24\b", statement + ): + return False + # A statement with no number refers back to the cap stated + # just before it, so it inherits that statement's framing. + context = statement + if not re.search(r"\b2[34]\b", statement): + context = previous + " " + statement + return re.search(includes_pk, context, re.IGNORECASE) is None + + statements = re.split(r"(?<=[.!?;])\s+|\n+", text) + claims_24_secondary = any( + _misplaces_pk(statement, statements[index - 1] if index else "") + for index, statement in enumerate(statements) ) if states_cap and not claims_24_secondary: passed = True @@ -5086,7 +5131,7 @@ def _concerns_the_index_cap(statement: str) -> bool: "key; the 24 includes it, so a table with a PK accepts 23" ) elif _has_positive_statement( - text, + capped_text, rf"\b(?:23|24)\s+(?:\w+(?:[- ]\w+)?\s+)?{index_noun}\b", subject, bound, diff --git a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py index 75239ea8..78177ff9 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py @@ -337,6 +337,32 @@ def test_indexes_per_table_veto_catches_the_off_by_one_phrasings() -> None: # an em dash is a separator like any other "The limit is 24 indexes per table — plus the primary key.", "The maximum is 24 indexes per table, excluding primary keys.", + "A table supports at most 24 indexes, aside from the primary key.", + "A table supports at most 24 indexes, separate from the primary key.", + "A table supports at most 24 indexes, exclusive of the primary key.", + "The 24-index limit per table doesn't include the primary key.", + "The 24-index limit per table doesn't count the primary key.", + "A table can have at most 24 indexes. The primary key is excluded.", + # a bare "23 indexes" elsewhere does not correct the claim + "A table supports 24 secondary indexes. So 23 indexes per table fits " + "easily.", + "DSQL allows 24 indexes per table, plus the primary key.", + "You can create up to 24 indexes on a table. The primary key index is " + "separate and doesn't count against this limit.", + "Each table can have up to 24 secondary indexes.", + "The limit is 24 indexes per table. Your primary key is not one of " + "them, so all 24 can be secondary indexes.", + "DSQL supports 24 indexes per table, and because the primary key is " + "part of the table storage rather than a separate index, all 24 slots " + "are available for your own indexes.", + "Max 24 indexes per table (not counting the primary key).", + "A table supports up to 24 indexes, so with your primary key you could " + "have 25 in total.", + "A DSQL table supports up to 24 indexes in addition to its primary key " + "index.", + "You get 24 indexes per table on top of the primary key.", + "Tables are limited to 24 user-defined indexes; the primary key is not " + "counted.", ) for text in wrong: @@ -352,8 +378,8 @@ def test_indexes_per_table_keeps_the_contrastive_exemption() -> None: Pre-empting the misconception, or contrasting with PostgreSQL, is the most useful answer a reader can get. The veto is cancelled when the response also - states the 23-secondary consequence positively, which is what those answers - carry and what a bare wrong claim does not. + states the inclusive framing and the 23 consequence, which is what those + answers carry and what a bare wrong claim does not. """ eval_item = { "prompt": "How many indexes can a DSQL table have?", @@ -367,6 +393,128 @@ def test_indexes_per_table_keeps_the_contrastive_exemption() -> None: "A common mistake is to assume 24 secondary indexes. The limit is 24 " "indexes per table including the primary key, leaving 23 secondary " "indexes.", + "It's tempting to read the limit as 24 secondary indexes, but the 24 " + "includes the primary key, so a table with a PK can add only 23 " + "indexes of its own.", + "A common mistake is to assume 24 secondary indexes. The limit is 24 " + "indexes per table including the primary key, so the table takes 23 " + "indexes besides it.", + ) + + for text in correct: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["passed"] == 1, text # nosec B101 + + +def test_indexes_per_table_accepts_upper_bound_wording() -> None: + """A negated bound states the cap; it does not deny it. + + "cannot have more than 24" and "doesn't allow more than 24" read as + negations to the shared check, and a curly apostrophe in "can’t exceed" + matched no bound at all, so each was graded as no mention. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + correct = ( + "A table cannot have more than 24 indexes, including the primary key.", + "A table can’t exceed 24 indexes, including the primary key.", + "Aurora DSQL doesn't allow more than 24 indexes on a single table, and " + "the primary key counts toward that limit, which leaves you 23 " + "secondary indexes.", + "A table cannot have more than 24 indexes — the primary key counts as " + "one of them.", + # a negated index mention earlier must not hide the cap after it + "Unlike standard PostgreSQL, which has no fixed index limit, Aurora " + "DSQL allows **at most 24 indexes per table**, counting the primary " + "key. That leaves 23 for secondary indexes.", + ) + + for text in correct: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["passed"] == 1, text # nosec B101 + + +def test_indexes_per_table_accepts_correct_pk_framings() -> None: + """Placing the PK outside 23, or after an inclusive cap, is correct. + + The veto fired on "plus the primary key" whatever the number, and on a + following sentence whose inclusive framing sat in the sentence before. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + correct = ( + "A table can have 23 secondary indexes plus the primary key.", + "A table can have at most 24 indexes including the primary key. In " + "addition to the primary key, create only the secondary indexes you " + "need.", + "A table supports at most 24 indexes, including the primary key. In " + "addition to the primary key, you can create 23 secondary indexes.", + ) + + for text in correct: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["passed"] == 1, text # nosec B101 + + +def test_indexes_per_table_accepts_the_no_pk_caveat() -> None: + """A table with no primary key really does take 24 secondary indexes. + + Answers add this as a caveat to the correct rule, and "24 secondary + indexes" in that statement is true rather than the off-by-one. + """ + eval_item = { + "prompt": "How many indexes can a DSQL table have?", + "expectations": ["Mentions the 24 indexes per table limit"], + "grader": "regex", + } + correct = ( + "No. Aurora DSQL allows at most 24 indexes per table, including the " + "primary key, so a table with a primary key gets 23 secondary " + "indexes. A table with no primary key can use all 24 for secondary " + "indexes.", + "No, 30 is over the cap. The limit is 24 indexes per table. If the " + "table has a primary key, it uses one of those slots, leaving 23 " + "secondary indexes; without a primary key you could create 24 " + "secondary indexes.", + "Not possible: the maximum is 24 indexes per table, counting the " + "primary key. That leaves 23 secondary indexes (24 if the table has " + "no primary key).", + "DSQL caps each table at 24 indexes including the primary key, i.e. " + "23 secondary indexes. Note: a table created without a primary key " + "can have 24 secondary indexes, since there is no PK index taking a " + "slot.", + "No. The maximum is 24 indexes per table, including the primary key. " + "In practice that means 23 secondary indexes on a table with a " + "primary key, or 24 on a table without one.", + "24 indexes per table is the hard cap, including the primary key (23 " + "secondary indexes). Edge case: a no-PK table accepts 24 secondary " + "indexes.", + "The limit is 24 indexes per table, including the primary key, so 23 " + "secondary indexes with a PK. Tables without a declared primary key " + "use a hidden row ID that does not count toward the limit, so they " + "accept 24 secondary indexes.", + "No: max 24 indexes per table including the primary key -> 23 " + "secondary indexes. (Without a primary key the full 24 are available " + "for secondary indexes, but every DSQL table should have a primary " + "key.)", + "No. A table can have at most 24 indexes, including the primary key. " + "A table without a primary key can have 24 secondary indexes.", + "Not quite: the cap is 24 indexes per table, including the primary " + "key, so with a PK you can add 23 more. A table with no primary key " + "can hold 24 secondary indexes.", + "**No.** Aurora DSQL limits a table to **24 indexes**, and the " + "primary key counts as one of them, leaving 23 for secondary indexes. " + "If you skip the primary key, you can create 24 secondary indexes.", + # the leading "No -" answers the question; it does not negate the cap + "No - DSQL supports up to 24 indexes per table, including the primary " + "key (so 23 secondary indexes for a table with a PK, 24 for one " + "without).", ) for text in correct: @@ -387,14 +535,16 @@ def test_indexes_per_table_ignores_unrelated_counts() -> None: "grader": "regex", } - graded = RUNNER.grade_eval( - eval_item, - _regex_run_result( - "A table supports 24 columns and 8 indexes per index key." - ), + unrelated = ( + "A table supports 24 columns and 8 indexes per index key.", + # the number counts the noun right after it, not a later "index" + "You can have up to 24 columns per index on a table.", + "A DSQL migration can take up to 24 hours per table index build.", ) - assert graded["summary"]["failed"] == 1 # nosec B101 + for text in unrelated: + graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) + assert graded["summary"]["failed"] == 1, text # nosec B101 # 23 is the secondary count, not the total, so a bare "23 indexes per # table" is wrong and must not satisfy the cap just because the relaxed From 20b2e90289b8161e151669272b4a3187b85da7db Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Thu, 8 Oct 2026 09:02:35 -0700 Subject: [PATCH 3/8] fix(dsql): tighten index-limit grader, link EOF rule The INDEXES_PER_TABLE grader still misgraded close variants of the review probes. "up to 24 hours" after an index noun passed as the cap, and correct answers failed: one naming DSQL rather than the table, the no-primary-key caveat on its own (its "no" read as a negation), and "23 secondary plus the primary key, 24 in total". Each now grades correctly; five new test strings cover them. occ-retry-patterns.md now links to the confirm-or-stop rule in troubleshooting.md instead of offering idempotency as an alternative to confirming. development-guide.md trims the index limit to one clause. --- .../dsql/references/development-guide.md | 2 +- .../dsql/references/occ-retry-patterns.md | 4 +- .../dsql/scripts/run_functional_evals.py | 38 +++++++++++++------ .../dsql/scripts/test_run_functional_evals.py | 7 ++++ 4 files changed, 36 insertions(+), 15 deletions(-) diff --git a/plugins/databases-on-aws/skills/dsql/references/development-guide.md b/plugins/databases-on-aws/skills/dsql/references/development-guide.md index c1221da7..5968ffa1 100644 --- a/plugins/databases-on-aws/skills/dsql/references/development-guide.md +++ b/plugins/databases-on-aws/skills/dsql/references/development-guide.md @@ -72,7 +72,7 @@ effortless scaling, multi-region viability, among other advantages. - REQUIRED: **at most one DDL statement** per operation - ALWAYS separate schema (DDL) and data (DML) changes - MUST use **`CREATE INDEX ASYNC`:** No synchronous creation (verify limits via `awsknowledge`: `aurora dsql index limits`) - - MAXIMUM: **24 indexes per table**, counting every index — the primary key, `UNIQUE` constraints, and ones still building ([details](troubleshooting.md#exceeded-limits)) + - MAXIMUM: **24 indexes per table**, including the primary key ([details](troubleshooting.md#exceeded-limits)) - MAXIMUM: **8 columns per index** - **MUST** verify index is ready before relying on it: `SELECT indisvalid FROM pg_index WHERE indexrelid = 'index_name'::regclass` — queries work but skip the index until `indisvalid = true` - MUST use **`ALTER TABLE ASYNC ... VALIDATE CONSTRAINT`** for constraint validation: No synchronous validation diff --git a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md index 1e9b879f..cc32cd2b 100644 --- a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md +++ b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md @@ -58,8 +58,8 @@ the marker before each sleep so an exhausted loop records which cause it was ret An error that arrives as a dropped connection rather than a SQLSTATE — `SSL SYSCALL error: EOF detected` — leaves the commit outcome **unknown**, because the connection closed without a reply. -Do not feed it to this loop. Reconnect and confirm whether the write landed, or make the write -idempotent by design first; see [Idempotent Transaction Design](#idempotent-transaction-design). +Do not feed it to this loop; follow the confirm-or-stop rule in +[troubleshooting.md](troubleshooting.md#rejections-and-constraint-violations). --- diff --git a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py index d77fb4ef..4c81087c 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py @@ -4955,7 +4955,9 @@ def grade_eval( # statement, so extend it when a new phrasing turns up, and add a case # to test_run_functional_evals.py alongside it. elif rule is AssertionRule.INDEXES_PER_TABLE: - subject = r"\btables?\b" + # "DSQL allows 23 secondary indexes" names no table but states the + # same per-table cap. + subject = r"\b(?:tables?|DSQL)\b" # "cap"/"capped at" is as common as "limit" in answers about this and # was previously graded as no mention at all. "can have"/"supports" # and a bare "per table" carry the same force in the table and bullet @@ -4977,6 +4979,18 @@ def grade_eval( text, flags=re.IGNORECASE, ) + # A table with no PK really does take 24 secondary indexes, so the + # caveat is true. "without the primary key" is left out: it reads + # as "excluding" as often as "on a table that has none". + primary_key = r"\b(?:primary\s+keys?|PKs?)\b" + no_pk_caveat = ( + r"\b(?:no|without\s+(?:a|any)|skip(?:s|ping)?\s+(?:the|a))" + r"[\s-]+(?:declared\s+|explicit\s+)?" + primary_key + ) + # The "no" in that caveat reads as a negation of the cap beside it. + capped_text = re.sub( + no_pk_caveat, "PK-less table", capped_text, flags=re.IGNORECASE + ) # Singular included: "the 24-index limit" and "max 24 index per # table" are both ordinary phrasings, and graded as no mention at # all while this matched only the plural. @@ -4987,6 +5001,11 @@ def grade_eval( # Noun-first ("indexes per table: 24") crosses a short run with no # other digit, which keeps "24 columns and 8 indexes" out. gap = r"[^.!?\n\d]{0,40}?" + # "up to 24 hours" and "24 columns" put another unit on the number. + not_other_unit = ( + r"(?![\s-]+(?:hours?|hrs?|minutes?|mins?|seconds?|secs?|days?" + r"|columns?|rows?|bytes?|characters?|connections?)\b)" + ) secondary_adj = ( r"(?:secondary|non-?primary(?:[- ]key)?|non-?PK|additional|extra" r"|user(?:-defined)?)" @@ -4996,7 +5015,7 @@ def grade_eval( # positive number-first one after it. cap_shapes = ( rf"\b24\b[^\w.!?\n]{{0,4}}(?:[\w-]+\s+)?{index_noun}\b", - rf"\b{index_noun}\b{gap}\b24\b", + rf"\b{index_noun}\b{gap}\b24\b{not_other_unit}", rf"\b23\s+{secondary_adj}\s+{index_noun}\b", # Noun-first for 23 must keep the "secondary" qualifier: a bare # "23 indexes per table" is itself wrong, since 23 is the @@ -5012,7 +5031,6 @@ def grade_eval( # clause ("Besides the primary key, a table supports 24 indexes") and # an em-dashed trailer ("24 indexes per table - plus the primary key") # are caught alongside the unpunctuated form. - primary_key = r"\b(?:primary\s+keys?|PKs?)\b" _exclusion_phrase = ( r"(?:in\s+addition\s+to|plus|besides|beyond|on\s+top\s+of" r"|excluding|not\s+counting|apart\s+from|aside\s+from" @@ -5055,13 +5073,6 @@ def grade_eval( rf"|\b25\s+(?:{index_noun}\s+)?(?:in\s+)?total\b" r"|\btotal\s+of\s+25\b" ) - # A table with no PK really does take 24 secondary indexes, so the - # caveat is true. "without the primary key" is left out: it reads - # as "excluding" as often as "on a table that has none". - no_pk_caveat = ( - r"\b(?:no|without\s+(?:a|any)|skip(?:s|ping)?\s+(?:the|a))" - r"[\s-]+(?:declared\s+|explicit\s+)?" + primary_key - ) # A genuine PostgreSQL contrast or pre-emption quotes "24 secondary" # to correct it, and carries both the inclusive framing and the 23 # consequence. A bare "23 indexes" on its own does not cancel. @@ -5105,9 +5116,12 @@ def _misplaces_pk(statement: str, previous: str) -> bool: return True if not re.search(excludes_pk, statement, re.IGNORECASE): return False - # "23 secondary indexes plus the primary key" is the right sum. + # "23 secondary indexes plus the primary key" is the right sum, + # with or without the "24 in total" that follows from it. if re.search(r"\b23\b", statement) and not re.search( - r"\b24\b", statement + rf"\b24\b(?!\s+(?:{index_noun}\s+)?(?:in\s+)?total\b)", + statement, + re.IGNORECASE, ): return False # A statement with no number refers back to the cap stated diff --git a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py index 78177ff9..e6e6681c 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py @@ -450,6 +450,9 @@ def test_indexes_per_table_accepts_correct_pk_framings() -> None: } correct = ( "A table can have 23 secondary indexes plus the primary key.", + # names DSQL rather than the table, and adds the total + "DSQL allows 23 secondary indexes plus the primary key.", + "DSQL allows 23 secondary indexes plus the primary key, 24 in total.", "A table can have at most 24 indexes including the primary key. In " "addition to the primary key, create only the secondary indexes you " "need.", @@ -515,6 +518,8 @@ def test_indexes_per_table_accepts_the_no_pk_caveat() -> None: "No - DSQL supports up to 24 indexes per table, including the primary " "key (so 23 secondary indexes for a table with a PK, 24 for one " "without).", + # the caveat on its own, with no inclusive rule beside it + "A table with no primary key accepts 24 secondary indexes.", ) for text in correct: @@ -540,6 +545,8 @@ def test_indexes_per_table_ignores_unrelated_counts() -> None: # the number counts the noun right after it, not a later "index" "You can have up to 24 columns per index on a table.", "A DSQL migration can take up to 24 hours per table index build.", + "A large table's index build can take up to 24 hours to finish.", + "Building an index on a big table can take up to 24 hours.", ) for text in unrelated: From cf16a4efb684e54bc8b76b35d54bf126f5137bea Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Thu, 8 Oct 2026 12:30:00 -0700 Subject: [PATCH 4/8] fix(dsql): move index PK check from regex to LLM judge The INDEXES_PER_TABLE regex tried to veto every phrasing that puts the primary key outside the 24, and each probe round found another that slipped through. It now checks only that the cap is stated, as 24 per table or 23 secondary, and keeps the guards against other units ("24 hours", "24 columns") and upper-bound wording. Whether the 24 counts the primary key moves to eval 28, an LLM-judge eval: a PostgreSQL table with a PK, a UNIQUE constraint and 23 other indexes needs 25 slots in DSQL and does not fit. Eval 3's expectation returns to its original wording, the PK-inclusive alias is dropped, and the veto tests go with the veto. --- tools/evals/databases-on-aws/README.md | 9 +- tools/evals/databases-on-aws/dsql/evals.json | 14 +- .../dsql/scripts/run_functional_evals.py | 189 ++---------- .../dsql/scripts/test_run_functional_evals.py | 292 +----------------- 4 files changed, 50 insertions(+), 454 deletions(-) diff --git a/tools/evals/databases-on-aws/README.md b/tools/evals/databases-on-aws/README.md index 7657ebbe..b2c9d86e 100644 --- a/tools/evals/databases-on-aws/README.md +++ b/tools/evals/databases-on-aws/README.md @@ -14,7 +14,7 @@ their recorded results, and runner files: tools/evals/databases-on-aws/ ├── README.md # This file — top-level index └── dsql/ # Aurora DSQL skill evals - ├── evals.json # Tier 2: functional evals (27 prompts, 118 assertions) + ├── evals.json # Tier 2: functional evals (28 prompts, 122 assertions) ├── dsql_lint_evals.json # dsql_lint workflow (4 prompts, 20 assertions) ├── pg_migration_evals.json # PostgreSQL migrations (17 prompts, 90 assertions) ├── pg_migration_hallucination_evals.json # Migration hallucinations (3 prompts, 14 assertions) @@ -96,13 +96,13 @@ mise exec -- python tools/evals/databases-on-aws/dsql/scripts/run_functional_eva --verbose ``` -**What it checks** (27 eval prompts, 118 assertions total): +**What it checks** (28 eval prompts, 122 assertions total): | Eval | Focus | Grader | Key assertions | | ------------------------------ | --------------------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 1. Transaction limits | MCP delegation | regex | Calls `awsknowledge`, cites 3,000 row-modification limit, recommends batching | | 2. Multi-tenant schema | Correctness | LLM judge | Uses non-null tenant keys, tenant-scoped foreign keys, `CREATE INDEX ASYNC`, separate DDL txns | -| 3. Index limits | MCP delegation | regex | Calls `awsknowledge`, states the 24-index limit without framing it as excluding the PK, suggests alternatives | +| 3. Index limits | MCP delegation | regex | Calls `awsknowledge`, cites 24 index limit, suggests alternatives | | 4. Python connection | Language routing | regex | Recommends DSQL Python Connector, IAM auth, 15-min token expiry, SSL | | 5. Column type change | DDL migration routing | LLM judge | Table Recreation Pattern, dependency gate, batching, user confirmation | | 6. JSON column storage | Type guidance | LLM judge | Recommends `JSONB` (or `JSON`) as the column type for queryable structured data | @@ -127,6 +127,7 @@ mise exec -- python tools/evals/databases-on-aws/dsql/scripts/run_functional_eva | 25. EOF at COMMIT | Troubleshooting | LLM judge | Commit outcome unknown, bare retry can double-debit, reconnect and confirm, addresses the unconfirmable case, not an ordinary `40001` retry | | 26. Catalog change (OC001) | OCC retry | LLM judge | `GRANT`/`REVOKE` raise `OC001` like DDL, the session's cached catalog is stale rather than overlapping the change, retry clears a one-shot, serialize if persistent, stays in the `40001` loop | | 27. Key size too large | Limits | LLM judge | Budget combined across key columns, ~1,981 bytes for one column, secondary index shares the primary key's budget, bytes not characters, shorten or hash | +| 28. Index count on migration | Limits | LLM judge | Counts the PK and `UNIQUE` indexes inside the 24, concludes 25 does not fit, never 24 secondary plus the PK, consolidates rather than raising | ### Grader modes @@ -135,7 +136,7 @@ Every eval consumed by `run_functional_evals.py` **MUST** declare one of two gra `"schema_version": 2`; result artifacts report schema and grading-protocol version `2` because the stricter grading and incomplete-run semantics are not directly comparable with older runs. -- **Regex / tool-call**: fast, cheap, deterministic. Each accepted assertion maps to a registered rule; the schema rejects assertions without one. Dedicated rules validate exact tool behavior and safety constraints. Compatibility rules retained for legacy corpora use polarity-aware keyword scoring. Limit rules bind values to their subject, such as 3,000 rows per transaction, 24 indexes per table (counting the primary key), and 8 columns per index. The index rule additionally vetoes the common off-by-one — stating the 24 as excluding the primary key — rather than only checking the number is present. +- **Regex / tool-call**: fast, cheap, deterministic. Each accepted assertion maps to a registered rule; the schema rejects assertions without one. Dedicated rules validate exact tool behavior and safety constraints. Compatibility rules retained for legacy corpora use polarity-aware keyword scoring. Limit rules bind values to their subject, such as 3,000 rows per transaction, 24 indexes per table, and 8 columns per index. Whether an answer counts the primary key inside the 24 is left to eval 28's judge. - **LLM judge**: runs a tool-free `claude -p` once per expectation with the complete redacted final answer, bounded call and result metadata, a complete tool-name inventory, the user prompt, and the assertion. Answers longer than 18,000 redacted characters are ungraded instead of being truncated. Tool-result bodies are explicitly untrusted and cannot independently satisfy assertions about what the agent presents or explains. The judge returns `{passed, evidence}`. Use it for semantic assertions where paraphrasing, negation, or synonym coverage makes regex brittle, such as "Does NOT recommend X." Each assertion incurs model-dependent cost and latency. Select the judge independently of the subject via `--judge-model` (defaults to the CLI default) diff --git a/tools/evals/databases-on-aws/dsql/evals.json b/tools/evals/databases-on-aws/dsql/evals.json index 19837feb..bc41faf6 100644 --- a/tools/evals/databases-on-aws/dsql/evals.json +++ b/tools/evals/databases-on-aws/dsql/evals.json @@ -36,7 +36,7 @@ "expected_output": "Explains the 24 index per table limit, counting the primary key, so 23 secondary indexes; verifies via awsknowledge, suggests alternatives like composite indexes or query redesign", "expectations": [ "Calls awsknowledge search_documentation with an index-related query", - "Mentions the 24 indexes per table limit, including the primary key", + "Mentions the 24 indexes per table limit", "Mentions the 8 columns per index limit", "Suggests alternatives such as composite indexes or reducing index count" ], @@ -341,6 +341,18 @@ "Recommends shortening or hashing the key columns rather than attempting to raise the limit" ], "grader": "llm_judge" + }, + { + "id": 28, + "prompt": "I'm migrating a PostgreSQL orders table to Aurora DSQL. It has a primary key, a UNIQUE constraint on order_number, and 23 other indexes for reporting queries. Will it fit, and if not, what should I change?", + "expected_output": "Explains that the 24-index limit counts every index on the table, including the primary key and the index behind the UNIQUE constraint, so the table needs 25 slots and does not fit; suggests consolidating indexes", + "expectations": [ + "States that the 24-index limit counts every index on the table, including the primary key and the index behind the UNIQUE constraint", + "Concludes that the table needs 25 indexes, so it does not fit and at least one index must be dropped or merged", + "Does NOT state that 24 secondary indexes are allowed in addition to the primary key", + "Suggests consolidating indexes, for example into composite indexes, rather than trying to raise the limit" + ], + "grader": "llm_judge" } ] } diff --git a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py index 4c81087c..c044c43d 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/run_functional_evals.py @@ -3194,8 +3194,6 @@ def _build_judge_evidence(run_result: dict) -> str: AssertionRule.TRANSACTION_SIZE_LIMIT, "mentions the 24 indexes per table limit": AssertionRule.INDEXES_PER_TABLE, - "mentions the 24 indexes per table limit, including the primary key": - AssertionRule.INDEXES_PER_TABLE, "mentions the 8 columns per index limit": AssertionRule.COLUMNS_PER_INDEX, "suggests alternatives such as composite indexes or reducing index count": @@ -4948,29 +4946,21 @@ def grade_eval( evidence = "No mention of 10 MiB data size limit found" # --- Assertion: 24 indexes --- - # The 24 counts every index, the primary key's included, so a table with - # a PK takes 23 secondary indexes. The veto below fails the off-by-one: - # 24 stated as secondary, or the PK placed outside the 24 ("plus", - # "excluding", "does not count"). It is a closed list matched per - # statement, so extend it when a new phrasing turns up, and add a case - # to test_run_functional_evals.py alongside it. + # Checks only that the cap is stated, as 24 per table or 23 secondary. + # Whether the answer counts the primary key inside the 24 turns on + # paraphrase and negation, so eval 28 leaves that to the LLM judge. elif rule is AssertionRule.INDEXES_PER_TABLE: # "DSQL allows 23 secondary indexes" names no table but states the # same per-table cap. subject = r"\b(?:tables?|DSQL)\b" - # "cap"/"capped at" is as common as "limit" in answers about this and - # was previously graded as no mention at all. "can have"/"supports" - # and a bare "per table" carry the same force in the table and bullet - # shapes a doc-quoting answer uses, where no bound word appears at all. bound = ( r"\b(?:limit|maximum|max|at\s+most|up\s+to|cap(?:ped|s)?|" r"no\s+more\s+than|" r"can\s+have|supports?|allows?|accepts?|permits?|holds?|" r"per\s+tables?)" ) - # "cannot have more than 24" and "doesn't allow more than 24" are - # upper bounds, but the shared negation check reads them as denials. - # Rewrite them, with either apostrophe, to "at most" before matching. + # "cannot have more than 24" is an upper bound, but the shared + # negation check reads it as a denial, so rewrite it to "at most". capped_text = re.sub( r"\b(?:cannot|can\s+not|(?:do|does|may|must|will)\s+not" r"|(?:ca|do|does|must|wo)n['’]t)\s+" @@ -4979,29 +4969,20 @@ def grade_eval( text, flags=re.IGNORECASE, ) - # A table with no PK really does take 24 secondary indexes, so the - # caveat is true. "without the primary key" is left out: it reads - # as "excluding" as often as "on a table that has none". - primary_key = r"\b(?:primary\s+keys?|PKs?)\b" - no_pk_caveat = ( - r"\b(?:no|without\s+(?:a|any)|skip(?:s|ping)?\s+(?:the|a))" - r"[\s-]+(?:declared\s+|explicit\s+)?" + primary_key - ) - # The "no" in that caveat reads as a negation of the cap beside it. + # The "no" in "a table with no primary key accepts 24" is not a + # negation of the cap beside it. capped_text = re.sub( - no_pk_caveat, "PK-less table", capped_text, flags=re.IGNORECASE + r"\b(?:no|without\s+(?:a|any))[\s-]+(?:primary\s+keys?|PKs?)\b", + "PK-less table", + capped_text, + flags=re.IGNORECASE, ) - # Singular included: "the 24-index limit" and "max 24 index per - # table" are both ordinary phrasings, and graded as no mention at - # all while this matched only the plural. index_noun = r"(?:indexes|indices|index)" - # A number counts the noun right after it, so number-first allows - # only markup and one modifier ("**24** indexes", "24-index", - # "24 secondary indexes"), which keeps "24 columns per index" out. - # Noun-first ("indexes per table: 24") crosses a short run with no - # other digit, which keeps "24 columns and 8 indexes" out. + # Number-first allows one modifier ("24 secondary indexes", "24-index") + # so "24 columns per index" stays out. Noun-first crosses a short run + # with no other digit, and rejects another unit after the number + # ("index builds take up to 24 hours"). gap = r"[^.!?\n\d]{0,40}?" - # "up to 24 hours" and "24 columns" put another unit on the number. not_other_unit = ( r"(?![\s-]+(?:hours?|hrs?|minutes?|mins?|seconds?|secs?|days?" r"|columns?|rows?|bytes?|characters?|connections?)\b)" @@ -5010,154 +4991,30 @@ def grade_eval( r"(?:secondary|non-?primary(?:[- ]key)?|non-?PK|additional|extra" r"|user(?:-defined)?)" ) - # Each shape is matched on its own: in one alternation a negated - # noun-first match ("no fixed index limit, ... 24") consumes the - # positive number-first one after it. + # 23 counts only with the qualifier: a bare "23 indexes per table" + # is wrong, since 23 is the secondary count and 24 the total. cap_shapes = ( rf"\b24\b[^\w.!?\n]{{0,4}}(?:[\w-]+\s+)?{index_noun}\b", rf"\b{index_noun}\b{gap}\b24\b{not_other_unit}", rf"\b23\s+{secondary_adj}\s+{index_noun}\b", - # Noun-first for 23 must keep the "secondary" qualifier: a bare - # "23 indexes per table" is itself wrong, since 23 is the - # secondary count and 24 is the total. rf"\b{secondary_adj}\s+{index_noun}\b{gap}\b23\b", ) - states_cap = any( + if any( _has_positive_statement(capped_text, shape, subject, bound) for shape in cap_shapes - ) - # Phrases that place the primary key OUTSIDE the 24. Matched anywhere - # in the statement rather than adjacent to the number, so a fronted - # clause ("Besides the primary key, a table supports 24 indexes") and - # an em-dashed trailer ("24 indexes per table - plus the primary key") - # are caught alongside the unpunctuated form. - _exclusion_phrase = ( - r"(?:in\s+addition\s+to|plus|besides|beyond|on\s+top\s+of" - r"|excluding|not\s+counting|apart\s+from|aside\s+from" - r"|other\s+than|separate\s+from|exclusive\s+of" - r"|not\s+includ(?:e|es|ing)" - r"|do(?:es)?\s+not\s+(?:include|count)" - r"|do(?:es)?n['’]t\s+(?:include|count))" - r"\s+(?:the\s+|your\s+|its\s+|a\s+|that\s+)?" - ) - # "the primary key does not count toward the limit" states the same - # error with the subject first, and lands in its own sentence often - # enough that requiring the number alongside it would miss it. - _pk_discounted = ( - r"(?:\s+index)?\s+(?:" - r"(?:does\s+not|do\s+not|does\s?n['’]t|do\s?n['’]t" - r"|is\s+not|isn['’]t)\s+" - r"(?:\w+\s+){0,3}(?:count|counted|include[ds]?|included)" - r"|(?:is|are)\s+(?:excluded|separate)\b)" - ) - excludes_pk = ( - _exclusion_phrase + primary_key - + r"|" + primary_key + _pk_discounted - ) - # The inclusive framing is correct, so it cancels the veto -- but only - # within the statement that triggered it, or the one before it when - # that statement carries no number of its own. Cancelling - # document-wide let one correct sentence license a wrong claim - # anywhere else in the answer. The lookbehinds keep "not including - # the primary key" from reading as the inclusive phrasing. - includes_pk = ( - r"(? bool: - if re.search(rf"\b{index_noun}\b", statement, re.IGNORECASE): - return True - return re.search( - other_limit_unit, statement, re.IGNORECASE - ) is None - - def _misplaces_pk(statement: str, previous: str) -> bool: - if not _concerns_the_index_cap(statement): - return False - if ( - re.search( - claims_24_secondary_outright, statement, re.IGNORECASE - ) - and not re.search(no_pk_caveat, statement, re.IGNORECASE) - and not corrects_24_secondary - ): - return True - if not re.search(excludes_pk, statement, re.IGNORECASE): - return False - # "23 secondary indexes plus the primary key" is the right sum, - # with or without the "24 in total" that follows from it. - if re.search(r"\b23\b", statement) and not re.search( - rf"\b24\b(?!\s+(?:{index_noun}\s+)?(?:in\s+)?total\b)", - statement, - re.IGNORECASE, - ): - return False - # A statement with no number refers back to the cap stated - # just before it, so it inherits that statement's framing. - context = statement - if not re.search(r"\b2[34]\b", statement): - context = previous + " " + statement - return re.search(includes_pk, context, re.IGNORECASE) is None - - statements = re.split(r"(?<=[.!?;])\s+|\n+", text) - claims_24_secondary = any( - _misplaces_pk(statement, statements[index - 1] if index else "") - for index, statement in enumerate(statements) - ) - if states_cap and not claims_24_secondary: + ): passed = True evidence = "Found a positive 24-index (or 23 secondary) limit" - elif claims_24_secondary: - evidence = ( - "Response states the 24-index cap as excluding the primary " - "key; the 24 includes it, so a table with a PK accepts 23" - ) elif _has_positive_statement( capped_text, rf"\b(?:23|24)\s+(?:\w+(?:[- ]\w+)?\s+)?{index_noun}\b", subject, bound, ): - # Distinguishes "stated a cap in a shape this rule does not - # recognise" from "never mentioned the limit". Collapsing the two - # sends whoever triages a red eval after a missing-doc problem - # when the real defect is a wrong number. - evidence = ( - "Stated an index cap in an unrecognised form; could not " - "confirm whether it counts the primary key" - ) + # Separates "stated a cap in a shape this rule does not + # recognise" from "never mentioned the limit", so a red eval + # is triaged as a wrong number rather than a missing doc. + evidence = "Stated an index cap in an unrecognised form" else: evidence = ( "No mention of 24 indexes per table (or 23 secondary) found" diff --git a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py index e6e6681c..759dbe55 100644 --- a/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py +++ b/tools/evals/databases-on-aws/dsql/scripts/test_run_functional_evals.py @@ -177,10 +177,10 @@ def test_safe_query_grading_rejects_explicit_f_string_injection() -> None: assert safe["summary"]["passed"] == 1 # nosec B101 -def test_indexes_per_table_evidence_distinguishes_wrong_from_absent() -> None: - """The three failure paths must each say *why* they failed. +def test_indexes_per_table_evidence_distinguishes_unrecognised_from_absent() -> None: + """The two failure paths must each say *why* they failed. - Collapsing "stated the cap as excluding the PK" into "never mentioned the + Collapsing "stated a cap in an unrecognised form" into "never mentioned the limit" sends whoever triages a red eval after a missing-doc or routing problem when the real defect is a wrong number. Nothing else in the suite asserts these strings, so a refactor that merged the branches would stay @@ -192,12 +192,6 @@ def test_indexes_per_table_evidence_distinguishes_wrong_from_absent() -> None: "grader": "regex", } - wrong = RUNNER.grade_eval( - eval_item, - _regex_run_result( - "A table supports at most 24 indexes excluding the primary key." - ), - ) unrecognised = RUNNER.grade_eval( eval_item, _regex_run_result("A table supports at most 23 unique indexes."), @@ -207,10 +201,6 @@ def test_indexes_per_table_evidence_distinguishes_wrong_from_absent() -> None: _regex_run_result("DSQL requires CREATE INDEX ASYNC."), ) - assert wrong["summary"]["failed"] == 1 # nosec B101 - assert ( # nosec B101 - "excluding the primary key" in wrong["expectations"][0]["evidence"] - ) assert unrecognised["summary"]["failed"] == 1 # nosec B101 assert ( # nosec B101 "unrecognised form" in unrecognised["expectations"][0]["evidence"] @@ -273,137 +263,10 @@ def test_indexes_per_table_accepts_the_singular_noun() -> None: assert graded["summary"]["passed"] == 1, text # nosec B101 -def test_indexes_per_table_veto_ignores_other_limits() -> None: - """A true "primary key does not count" clause about another limit is not the error. - - The primary key genuinely does not count toward the 1,600 lifetime column - ceiling. Matching that phrase without requiring the statement to concern - indexes made the grader fail a correct answer *and* report it as having - stated the cap wrongly -- the exact off-by-one it exists to police. - """ - eval_item = { - "prompt": "How many indexes can a DSQL table have?", - "expectations": ["Mentions the 24 indexes per table limit"], - "grader": "regex", - } - - graded = RUNNER.grade_eval( - eval_item, - _regex_run_result( - "A table supports at most 24 indexes, including the primary key. " - "Separately, the primary key does not count toward the 1,600 " - "lifetime column limit." - ), - ) - - assert graded["summary"]["passed"] == 1 # nosec B101 - - # The bare form, with no other limit named, must still veto. - still_wrong = RUNNER.grade_eval( - eval_item, - _regex_run_result( - "A table supports at most 24 indexes. The primary key does not " - "count toward that limit." - ), - ) - - assert still_wrong["summary"]["failed"] == 1 # nosec B101 - - -def test_indexes_per_table_veto_catches_the_off_by_one_phrasings() -> None: - """Every way of putting the primary key outside the 24 must fail. - - This is the error the rule exists to catch, so a phrasing that slips past it - is worse than a missing assertion: the suite reports the wrong answer as - correct. The inclusive guard that excuses a contrastive "24 secondary" - mention must not excuse these. - """ - eval_item = { - "prompt": "How many indexes can a DSQL table have?", - "expectations": ["Mentions the 24 indexes per table limit"], - "grader": "regex", - } - wrong = ( - "A table supports at most 24 indexes, not including the primary key.", - # the inclusive phrase must not license the wrong conclusion drawn - # from it in the same breath - "A table is capped at 24 indexes including the primary key, so you " - "get 24 secondary indexes.", - # the contradiction in a following sentence - "A table supports at most 24 indexes. The primary key does not count " - "toward that limit.", - # fronted exclusion, before the number - "Besides the primary key, each table supports a maximum of 24 indexes.", - # an em dash is a separator like any other - "The limit is 24 indexes per table — plus the primary key.", - "The maximum is 24 indexes per table, excluding primary keys.", - "A table supports at most 24 indexes, aside from the primary key.", - "A table supports at most 24 indexes, separate from the primary key.", - "A table supports at most 24 indexes, exclusive of the primary key.", - "The 24-index limit per table doesn't include the primary key.", - "The 24-index limit per table doesn't count the primary key.", - "A table can have at most 24 indexes. The primary key is excluded.", - # a bare "23 indexes" elsewhere does not correct the claim - "A table supports 24 secondary indexes. So 23 indexes per table fits " - "easily.", - "DSQL allows 24 indexes per table, plus the primary key.", - "You can create up to 24 indexes on a table. The primary key index is " - "separate and doesn't count against this limit.", - "Each table can have up to 24 secondary indexes.", - "The limit is 24 indexes per table. Your primary key is not one of " - "them, so all 24 can be secondary indexes.", - "DSQL supports 24 indexes per table, and because the primary key is " - "part of the table storage rather than a separate index, all 24 slots " - "are available for your own indexes.", - "Max 24 indexes per table (not counting the primary key).", - "A table supports up to 24 indexes, so with your primary key you could " - "have 25 in total.", - "A DSQL table supports up to 24 indexes in addition to its primary key " - "index.", - "You get 24 indexes per table on top of the primary key.", - "Tables are limited to 24 user-defined indexes; the primary key is not " - "counted.", - ) - - for text in wrong: - graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) - assert graded["summary"]["failed"] == 1, text # nosec B101 - assert ( # nosec B101 - "excluding the primary key" in graded["expectations"][0]["evidence"] - ), text -def test_indexes_per_table_keeps_the_contrastive_exemption() -> None: - """An answer may quote the wrong framing in order to correct it. - Pre-empting the misconception, or contrasting with PostgreSQL, is the most - useful answer a reader can get. The veto is cancelled when the response also - states the inclusive framing and the 23 consequence, which is what those - answers carry and what a bare wrong claim does not. - """ - eval_item = { - "prompt": "How many indexes can a DSQL table have?", - "expectations": ["Mentions the 24 indexes per table limit"], - "grader": "regex", - } - correct = ( - "In PostgreSQL you could have 24 secondary indexes, but DSQL's limit " - "of 24 indexes per table includes the primary key, so you get 23 " - "secondary indexes.", - "A common mistake is to assume 24 secondary indexes. The limit is 24 " - "indexes per table including the primary key, leaving 23 secondary " - "indexes.", - "It's tempting to read the limit as 24 secondary indexes, but the 24 " - "includes the primary key, so a table with a PK can add only 23 " - "indexes of its own.", - "A common mistake is to assume 24 secondary indexes. The limit is 24 " - "indexes per table including the primary key, so the table takes 23 " - "indexes besides it.", - ) - for text in correct: - graded = RUNNER.grade_eval(eval_item, _regex_run_result(text)) - assert graded["summary"]["passed"] == 1, text # nosec B101 def test_indexes_per_table_accepts_upper_bound_wording() -> None: @@ -438,11 +301,7 @@ def test_indexes_per_table_accepts_upper_bound_wording() -> None: def test_indexes_per_table_accepts_correct_pk_framings() -> None: - """Placing the PK outside 23, or after an inclusive cap, is correct. - - The veto fired on "plus the primary key" whatever the number, and on a - following sentence whose inclusive framing sat in the sentence before. - """ + """Answers that place the PK beside 23, or name DSQL, state the cap.""" eval_item = { "prompt": "How many indexes can a DSQL table have?", "expectations": ["Mentions the 24 indexes per table limit"], @@ -466,10 +325,10 @@ def test_indexes_per_table_accepts_correct_pk_framings() -> None: def test_indexes_per_table_accepts_the_no_pk_caveat() -> None: - """A table with no primary key really does take 24 secondary indexes. + """The "no" in "a table with no primary key" does not negate the cap. - Answers add this as a caveat to the correct rule, and "24 secondary - indexes" in that statement is true rather than the off-by-one. + A table with no PK really does take 24 secondary indexes, and answers add + it as a caveat to the rule. """ eval_item = { "prompt": "How many indexes can a DSQL table have?", @@ -566,22 +425,6 @@ def test_indexes_per_table_ignores_unrelated_counts() -> None: assert bare_23["summary"]["failed"] == 1 # nosec B101 -def test_indexes_per_table_alias_shares_the_rule() -> None: - """Both expectation strings must dispatch to the same rule. - - `evals.json` uses the PK-inclusive string; the short one is kept for - out-of-tree corpora. If either stops resolving, `validate_evals_data` - rejects the corpus at load rather than silently skipping the assertion. - """ - short = "Mentions the 24 indexes per table limit" - long = "Mentions the 24 indexes per table limit, including the primary key" - - rules = { - RUNNER.ASSERTION_RULES[RUNNER._normalize_assertion(text)] - for text in (short, long) - } - - assert rules == {RUNNER.AssertionRule.INDEXES_PER_TABLE} # nosec B101 def test_separate_ddl_grading_rejects_combined_transaction() -> None: @@ -2406,28 +2249,10 @@ def capture_judge_evidence(**kwargs): 1, ), ( - # The 24 counts the primary key, so this phrasing is wrong. + # Whether the 24 counts the PK is eval 28's judge's call, not this + # rule's. "Mentions the 24 indexes per table limit", "A table supports at most 24 secondary indexes.", - 0, - ), - ( - # Same error stated alongside the correct cap: a presence-only check - # passes this, which is why the rule vetoes the wrong claim. - "Mentions the 24 indexes per table limit", - "A table has a maximum of 24 indexes, so 24 secondary indexes.", - 0, - ), - ( - # The off-by-one without the word "secondary". - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes in addition to the primary key.", - 0, - ), - ( - # "including" is the correct framing and must not trip the veto. - "Mentions the 24 indexes per table limit", - "The limit is 24 indexes per table including the primary key.", 1, ), ( @@ -2435,105 +2260,6 @@ def capture_judge_evidence(**kwargs): "A table has at most 24 total indexes.", 1, ), - ( - # The PK-inclusive expectation string is an alias of the same rule; - # evals.json uses it, so it has to keep dispatching. - "Mentions the 24 indexes per table limit, including the primary key", - "The limit is 24 indexes per table including the primary key.", - 1, - ), - ( - "Mentions the 24 indexes per table limit, including the primary key", - "A table supports at most 24 secondary indexes.", - 0, - ), - # One case per excluding phrase the veto enumerates. Each of these states - # the off-by-one and must fail; an untested alternative is an untested - # claim, and a later edit that drops one of them would otherwise stay - # green. - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes plus the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indices besides the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes beyond the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes on top of the primary key.", - 0, - ), - ( - # The subtractive family. "excluding" is the most natural English - # phrasing of this error, so its absence was the widest hole. - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes excluding the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes not counting the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes apart from the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes other than the primary key.", - 0, - ), - ( - # A comma between the noun and the phrase must not rescue the wrong - # claim -- whether the veto fires cannot depend on punctuation. - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes, plus the primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes (in addition to the primary key).", - 0, - ), - ( - # "per table" intervening is the phrasing the rule's own subject - # pattern expects, so it has to be reachable. - "Mentions the 24 indexes per table limit", - "The maximum is 24 indexes per table plus the PK.", - 0, - ), - ( - # A possessive determiner instead of "the". - "Mentions the 24 indexes per table limit", - "A table supports at most 24 indexes in addition to your primary key.", - 0, - ), - ( - "Mentions the 24 indexes per table limit", - "The max is 24 indexes per table, which does not include the " - "primary key.", - 0, - ), - # Correct phrasings that must survive the widened veto. - ( - # Quoting the wrong framing to contrast with PostgreSQL, or to - # pre-empt the misconception, is the most useful answer a reader can - # get. The veto must not punish it. - "Mentions the 24 indexes per table limit", - "In PostgreSQL you can have 24 indexes plus the primary key, but a " - "DSQL table limits you to 24 total indexes including the PK.", - 1, - ), ( "Mentions the 24 indexes per table limit", "The cap is 24 indexes per table including the primary key, so you " From a90553c16cdddecb386d58658169fe1453f522b1 Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Thu, 8 Oct 2026 12:58:26 -0700 Subject: [PATCH 5/8] docs(dsql): split limits into their own reference troubleshooting.md loads on any error and had grown to 415 lines. The Limits and Error Codes section moves to limits-and-error-codes.md with its own SKILL.md row, loaded before sizing batches, keys or indexes and on a 54000/0A000/54011 error. troubleshooting.md keeps a pointer and drops to 223 lines. OC001 is now explained once, in occ-retry-patterns.md; the troubleshooting entry keeps the cause, marker table and fix. The 24-slot paragraph in index-conversion.md becomes a link. The SKILL.md OCC and size bullets are shorter but keep the facts evals 24 and 26 showed agents miss without them. Drops the measurement date, the view-size series and "observed here". Also drops the 54001 row and the 214 MiB figure, which did not reproduce on re-test. --- plugins/databases-on-aws/skills/dsql/SKILL.md | 30 +-- .../dsql/references/development-guide.md | 2 +- .../dsql/references/limits-and-error-codes.md | 177 ++++++++++++++ .../dsql/references/occ-retry-patterns.md | 6 +- .../pg-migrations/index-conversion.md | 6 +- .../skills/dsql/references/troubleshooting.md | 220 ++---------------- 6 files changed, 213 insertions(+), 228 deletions(-) create mode 100644 plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md diff --git a/plugins/databases-on-aws/skills/dsql/SKILL.md b/plugins/databases-on-aws/skills/dsql/SKILL.md index 927f034b..c5a86c4d 100644 --- a/plugins/databases-on-aws/skills/dsql/SKILL.md +++ b/plugins/databases-on-aws/skills/dsql/SKILL.md @@ -18,16 +18,17 @@ Load these files as needed for detailed guidance: ### Core: -| Reference | When to Load | Contains | -| --------------------------------------------------------- | --------------------------------------------------- | -------------------------------------------------------------------------------------- | -| [development-guide.md](references/development-guide.md) | ALWAYS before schema changes or DB operations | Best practices, DDL rules, transaction limits, foreign key constraints | -| [foreign-keys.md](references/foreign-keys.md) | MUST load for foreign key operations or migrations | FK syntax, actions, validation, tenant keys | -| [language.md](references/language.md) | MUST load for language-specific choices | Driver selection, DSQL Connectors, connection code | -| [access-control.md](references/access-control.md) | MUST load for roles, grants, or sensitive data | Scoped role setup, IAM-to-database role mapping | -| [troubleshooting.md](references/troubleshooting.md) | SHOULD load for errors or unexpected behavior | Limits/SQLSTATEs with exact messages, OCC and `23503`, FK, connections, cluster state | -| [dsql-examples.md](references/dsql-examples.md) | Load for implementation examples | Multi-tenant access, batch operations, identity and sequences, connection pooling | -| [onboarding.md](references/onboarding.md) | User requests "Get started with DSQL" | Interactive step-by-step guide | -| [occ-retry-patterns.md](references/occ-retry-patterns.md) | MUST load for OCC retry code or conflict mitigation | Connectors, `40001` retry, non-retryable `23503`, FK read conflicts, idempotent design | +| Reference | When to Load | Contains | +| ----------------------------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------- | +| [development-guide.md](references/development-guide.md) | ALWAYS before schema changes or DB operations | Best practices, DDL rules, transaction limits, foreign key constraints | +| [foreign-keys.md](references/foreign-keys.md) | MUST load for foreign key operations or migrations | FK syntax, actions, validation, tenant keys | +| [language.md](references/language.md) | MUST load for language-specific choices | Driver selection, DSQL Connectors, connection code | +| [access-control.md](references/access-control.md) | MUST load for roles, grants, or sensitive data | Scoped role setup, IAM-to-database role mapping | +| [troubleshooting.md](references/troubleshooting.md) | SHOULD load for errors or unexpected behavior | OCC and `23503`, FK, connections, cluster state | +| [limits-and-error-codes.md](references/limits-and-error-codes.md) | SHOULD load before sizing batches, keys or indexes, or on `54000`/`0A000`/`54011` | Every limit, rejection and quota with SQLSTATE and exact message | +| [dsql-examples.md](references/dsql-examples.md) | Load for implementation examples | Multi-tenant access, batch operations, identity and sequences, connection pooling | +| [onboarding.md](references/onboarding.md) | User requests "Get started with DSQL" | Interactive step-by-step guide | +| [occ-retry-patterns.md](references/occ-retry-patterns.md) | MUST load for OCC retry code or conflict mitigation | Connectors, `40001` retry, non-retryable `23503`, FK read conflicts, idempotent design | ### MCP: @@ -158,7 +159,7 @@ defaults that may change — when a user's decision depends on an exact limit, v | IDENTITY/SEQUENCE CACHE values | 1 or >= 65536 | `aurora dsql sequence cache` | | Supported column data types | See docs | `aurora dsql supported data types` | -**When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. For the full limit set with each SQLSTATE and its exact error message, see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes). +**When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. For the full limit set with each SQLSTATE and its exact error message, see [limits-and-error-codes.md](references/limits-and-error-codes.md). **Fallback:** If `awsknowledge` is unavailable, use the defaults above and flag that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). @@ -287,10 +288,9 @@ MUST load [system-diagnostics/workflow.md](references/system-diagnostics/workflo - **`awsknowledge` returns no results:** Use the default limits in the table above and note that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). - **`dsql_lint` unavailable or timing out:** See the Error Handling section of [dsql-lint.md](references/dsql-lint.md). Do not silently skip validation — inform the user and require explicit confirmation before proceeding with manual rules from [development-guide.md](references/development-guide.md). -- **OCC serialization error:** Retry the transaction. If persistent, check for hot-key contention. If the message contains `(OC001)` the session's cached schema catalog is older than a committed catalog change — any DDL, and also `GRANT`/`REVOKE`, even one that committed before the transaction began — not row contention or overlapping timing. Reads are hit too. A retry refreshes the cache, so a one-shot change clears on the first retry; if it persists, another session is still changing the catalog and you **MUST** serialize those changes. Match `(OC001)` as a substring, not as a suffix: some drivers append their own text such as `(SQLSTATE 40001)`. See [troubleshooting.md](references/troubleshooting.md). -- **Foreign key violation (`23503`):** Correct the relationship or referential action; **MUST NOT** - send it through the `40001` retry loop — see [troubleshooting.md](references/troubleshooting.md). -- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB; locked-row primary keys count toward the size limit. The 10 MiB check runs at `COMMIT`, and secondary index entries count toward it, so an indexed table has a smaller usable budget — see [troubleshooting.md](references/troubleshooting.md#limits-and-error-codes) for how to size it, and [batched-migration.md](references/ddl-migrations/batched-migration.md) for the batching pattern. +- **OCC serialization error:** Retry the transaction; if persistent, check for hot-key contention. `(OC001)` in the message means the session's cached schema catalog is older than a committed catalog change — any DDL or `GRANT`/`REVOKE`, even one finished before the transaction began — not row contention or overlapping timing, and reads are hit too. Keep it in the retry loop: a retry refreshes the cache, so a one-shot change clears on the first retry; if it persists, you **MUST** serialize the catalog changes. Match `(OC001)` as a substring — see [occ-retry-patterns.md](references/occ-retry-patterns.md). +- **Foreign key violation (`23503`):** Correct the relationship or referential action; **MUST NOT** send it through the `40001` retry loop — see [troubleshooting.md](references/troubleshooting.md). +- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB. The 10 MiB is checked at `COMMIT`, and secondary index entries and locked-row primary keys count toward it, so raw data size does not predict it — size from the `54000` DETAIL (see [limits-and-error-codes.md](references/limits-and-error-codes.md#exceeded-limits)), batching pattern in [batched-migration.md](references/ddl-migrations/batched-migration.md). - **Token expiration mid-operation:** Generate a fresh IAM token — see [authentication-guide.md](references/auth/authentication-guide.md). See [troubleshooting.md](references/troubleshooting.md) for other issues. ## Additional Resources diff --git a/plugins/databases-on-aws/skills/dsql/references/development-guide.md b/plugins/databases-on-aws/skills/dsql/references/development-guide.md index 5968ffa1..87bc7309 100644 --- a/plugins/databases-on-aws/skills/dsql/references/development-guide.md +++ b/plugins/databases-on-aws/skills/dsql/references/development-guide.md @@ -72,7 +72,7 @@ effortless scaling, multi-region viability, among other advantages. - REQUIRED: **at most one DDL statement** per operation - ALWAYS separate schema (DDL) and data (DML) changes - MUST use **`CREATE INDEX ASYNC`:** No synchronous creation (verify limits via `awsknowledge`: `aurora dsql index limits`) - - MAXIMUM: **24 indexes per table**, including the primary key ([details](troubleshooting.md#exceeded-limits)) + - MAXIMUM: **24 indexes per table**, including the primary key ([details](limits-and-error-codes.md#exceeded-limits)) - MAXIMUM: **8 columns per index** - **MUST** verify index is ready before relying on it: `SELECT indisvalid FROM pg_index WHERE indexrelid = 'index_name'::regclass` — queries work but skip the index until `indisvalid = true` - MUST use **`ALTER TABLE ASYNC ... VALIDATE CONSTRAINT`** for constraint validation: No synchronous validation diff --git a/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md b/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md new file mode 100644 index 00000000..5b17b6f6 --- /dev/null +++ b/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md @@ -0,0 +1,177 @@ +# Limits and Error Codes + +Every DSQL limit, rejection and cluster quota with its SQLSTATE and exact message. Load it before +sizing batches, keys or index sets, and to identify a `54000`, `0A000` or `54011` error. Other +errors are in [troubleshooting.md](troubleshooting.md). + +**Match on the message rather than the code.** `54000` and `0A000` each cover +more than a dozen of the rows below, so the code alone does not identify which row you are on. This +is a lookup rule for these tables, not a retry rule — classification elsewhere in the skill is +SQLSTATE-based, and `40001` remains the only retryable code (see +[occ-retry-patterns.md](occ-retry-patterns.md)). + +**MUST** match a stable prefix, not the whole string, and match it case-insensitively — case varies +even within one family (`Datatype limit …` for `char`/`varchar`, `datatype limit …` for `text`). +``, `` and `...` in the Message column are values DSQL interpolates at run time, so an +equality test against the cell never matches. If no message matches, **SHOULD** fall back to the +SQLSTATE class and verify the current limit via `awsknowledge` — message text is not a stable +contract. + +Values marked **observed** were measured on a single-Region cluster (PostgreSQL 16 +compatibility) rather than taken from the documentation. Where an observed value contradicts the +[quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html), +the row says so. Re-verify before a design decision depends on an exact number. + +Run `dsql_lint` to catch unsupported _features_ before execution; use this section for the +_limits_, which are only observable at run time. + +## Exceeded Limits + +| Limit | Value | SQLSTATE | Message | +| ----------------------------------- | ----------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | +| Row modifications per transaction | 3,000 | `54000` | `transaction row limit exceeded` | +| Transaction size | 10 MiB, incl. per-row and per-index overhead (see below) | `54000` | `transaction size limit 10mb exceeded`, with `DETAIL: Current transaction size mb > 10mb` | +| Transaction age | 5 minutes | `54000` | `transaction age limit of 300s exceeded`, with `DETAIL: Current transaction age s exceeds 300s` | +| Query temp space | 128 MiB; docs give `53200` and other text | `54000` | `query requires more than allowed temp space of 131072 KB to execute` | +| Extended statistics per table | 5 | `54000` | `more than 5 extended statistics per table are not allowed` | +| Schemas per database | 10, excluding `public` and `sys` | `54000` | `more than 10 schemas not allowed` | +| Indexes per table | 24, counting every index — see below | `54000` | `more than 24 indexes per table are not allowed` | +| Primary key or index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | +| Declared key size | 2,000 bytes, rejected at DDL time | `54000` | `key size greater than 2000 bytes not supported` | +| Row size | 2 MiB | `54000` | `maximum row size exceeded` | +| Non-index column size | 1 MiB; docs give the message below, a per-datatype one was seen | `54000` | docs: `maximum column size exceeded`; observed: `datatype limit greater than 1048576 bytes not supported for ` | +| View definition size | 131–524 KiB of SQL text observed, varies by shape; docs say 2 MiB | `54000` | `view definition too large` | +| `char` declared size | 4,096 bytes, rejected at DDL time | `54000` | `Datatype limit greater than 4096 bytes not supported for char` | +| `varchar` declared size | 65,535 bytes, rejected at DDL time | `54000` | `Datatype limit greater than 65535 bytes not supported for varchar` | +| `text` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for text` | +| `bytea` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for bytea` | +| `json` / `jsonb` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for jsonb` | +| Columns per index or primary key | 8 | `54011` | `more than 8 column keys in an index are not supported` | +| Columns per table, no primary key | 256 accepted, 257 rejected; docs and message both say 255 | `54011` | `tables can have at most 255 columns` | +| Columns per table, with primary key | 256 accepted, 257 rejected; message names an _index_ | `54011` | `cannot use more than 256 columns in an index` | +| Extended-statistics target | 100 | `22023` | `statistics target exceeds maximum allowed value of 100` | +| `numeric` precision | 1,000 | `22023` | `NUMERIC precision must be between 1 and 1000` | + +Several of these behave in ways the limit alone does not convey: + +- **The per-value 1 MiB limit is a hard bound on high-entropy data.** For random `text`, `base64`, + `bytea` and `jsonb`, 1,048,576 bytes is the largest value accepted and 1,048,577 the first + rejected — nothing fails early. Compressible values go much further. Size against raw byte count; + compression only ever buys headroom. +- **The 10 MiB transaction limit also counts index writes.** The check runs at `COMMIT` against an + accounted size that adds a cost per row and per secondary index entry written. The per-entry + cost grows with the index key plus the primary key: small for `int`, `uuid` or `timestamptz` + keys, several times the key width for wide `text` keys. A 500-row batch committed at most + 9.93 MiB of raw data with no secondary index, and 9.11 MiB with four on 100-byte `text` keys. + The row limit is documented as independent of secondary indexes; the size limit is not, and its + accounting is undocumented. Large compressible values, such as JSON over a few KB, can count + below their raw size. To size a batch, read the error: `DETAIL: Current transaction size mb > + 10mb` is the exact accounted size, so one over-limit trial on the real schema gives the ratio to + scale by. +- **The key-size budget is shared, in bytes, by every key column.** One column holds ~1,981 bytes + and each additional key column costs ~7 more, so an 8-column key gets ~241 bytes each. Bytes, + not characters — a 4-byte UTF-8 character key holds ~495 characters. A secondary index key also + carries the table's primary key columns, so a 500-byte primary key leaves ~1,474 bytes for the + index columns. Separately, DDL rejects a key whose _declared_ sizes sum past 2,000 bytes, leaving + the primary key out of that sum for a secondary index; a `varchar(2000)` key therefore passes + `CREATE` and still fails on write above ~1,981 bytes. The quotas page lists the primary key and + secondary index as two separate 1 KiB budgets; the observed behaviour is a single shared budget. +- **The view-definition limit is a 512 KiB internal budget, not a text length.** How much SQL + fits depends on the query's structure, so treat **~120 KiB** of definition text as a working + ceiling and size by trial when a view approaches it. +- **Every index counts toward the 24**: the primary key, the index behind each `UNIQUE` + constraint, and every `CREATE [UNIQUE] INDEX ASYNC`, including one still building + (`indisvalid = f`). A table with a primary key and one `UNIQUE` constraint has 22 slots left; + a table with no primary key has no hidden index and holds 24. Count with + `SELECT count(*) FROM pg_index WHERE indrelid = 'tbl'::regclass`. `DROP INDEX` frees a slot. +- **`char` and `varchar` limits are enforced on the _declared_ size at DDL time**, so an + oversized declaration fails at `CREATE TABLE`, not on first write. +- **Dropped columns keep counting.** The documented ceiling is 1,600 _cumulative_ columns including + dropped ones, so a table churned by repeated `ADD COLUMN`/`DROP COLUMN` during a migration can + hit `54011` while holding far fewer live columns. Recreate the table rather than continuing to + churn it — see [ddl-migrations/overview.md](ddl-migrations/overview.md). + +## Rejections and Constraint Violations + +Unsupported-feature list: [PostgreSQL compatibility — unsupported features](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html). Index syntax: [CREATE INDEX syntax support](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/create-index-syntax-support.html). + +These are not limits — the feature or the value is refused outright. + +| Cause | SQLSTATE | Message | +| --------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | +| Non-immutable function in an index expression | `42P17` | `functions in index expression must be marked IMMUTABLE` | +| Non-immutable generated-column expression | `42P17` | `generation expression is not immutable` | +| `serial` column type | `42704` | `type "serial" does not exist` | +| Array column type | `0A000` | `datatype text[] not supported` — the declared type is echoed back | +| Sort direction on an index key | `0A000` | `specifying sort order not supported for index keys` | +| Operator class on an index key | `0A000` | `opclass not supported for index keys` | +| Index on an unsupported column type | `0A000` | `datatype is not supported in a key` | +| `CREATE INDEX` without `ASYNC` | `0A000` | `unsupported mode. please use CREATE INDEX ASYNC.` | +| `CREATE INDEX CONCURRENTLY` | `0A000` | `CONCURRENTLY not supported for CREATE INDEX` | +| `ALTER TABLE ADD COLUMN` with any inline constraint | `0A000` | `ALTER TABLE ADD COLUMN with constraint not supported` | +| `ALTER COLUMN ... SET DATA TYPE` | `0A000` | `unsupported ALTER TABLE ALTER COLUMN ... SET DATA TYPE statement` | +| `ALTER COLUMN ... SET STATISTICS`, at any value | `0A000` | `unsupported ALTER TABLE ALTER COLUMN ... SET STATISTICS statement` | +| `CREATE PROCEDURE` | `0A000` | `PROCEDURE is not supported` | +| `CREATE DATABASE` | `0A000` | `unsupported statement: Createdb` | +| Post-creation foreign key without `NOT VALID` | `0A000` | `unsupported ALTER TABLE ADD CONSTRAINT statement` | +| Synchronous `VALIDATE CONSTRAINT` | `0A000` | `unsupported ALTER TABLE VALIDATE CONSTRAINT statement` | +| Foreign key violation — **not retryable** | `23503` | `insert or update on table ... violates foreign key constraint ...` | +| Duplicate against a unique index still building | `23505` | `duplicate key value violates unique constraint ...` | +| Any statement issued after a failure inside `BEGIN` | `25P02` | `current transaction is aborted, commands ignored until end of transaction block` | +| Protocol message over 10 MiB | **none** | No error reaches the client — see below | + +These mislead in practice: + +- **`25P02` masks the real error.** It is the code an agent sees most often, because anything + that keeps issuing statements after a failure inside a transaction block gets it in place of + the original cause. Read the _first_ error in the transaction, not the last. +- **An oversized protocol message closes the connection with no error message.** The client sees + only `SSL SYSCALL error: EOF detected`, which is indistinguishable from a transient network + fault, so a naive retry resends the same oversized statement forever. Treat an EOF on the first + statement after a large one as a size problem, not a network one. Note the AWS documentation + lists `08P01` / `FATAL: invalid message length` for this case; that error is not delivered. If + you _do_ see `08P01: invalid message length`, that is this case and the behaviour has changed — trust the error. + +**Any EOF on a statement that could have committed — `COMMIT`, or any write in autocommit mode — +leaves the commit outcome unknown**, whatever the cause, because the +connection closed before the server replied. This applies to the oversized-message case above and +equally to an EOF the agent classifies as a network fault — the size discriminator tells you why the +connection dropped, not whether the write landed. Before resending, an agent **MUST** reconnect and +confirm whether the write committed. Designing the write to be idempotent removes the need for that +check, but only if it was already idempotent — it is not something that can be applied after the +EOF arrives, and a bare retry of a non-idempotent write such as +`UPDATE accounts SET balance = balance - 100` double-applies it. See +[occ-retry-patterns.md](occ-retry-patterns.md#idempotent-transaction-design). + +**If the outcome cannot be confirmed, an agent MUST NOT retry.** A blind +`balance = balance - 100` has no observable trace that distinguishes "applied once" from "not +applied", so there is nothing to query. **MUST** report the unresolved write to the user with the +transaction's identifying values and stop; a duplicated debit is worse than a reported gap. Avoid the situation rather than resolve it: write a +correlation id the transaction itself records, so the question becomes answerable next time. + +The threshold is the _protocol message_ size, which a client does not see directly: a batched or +parameterised multi-row insert can exceed it well below 10 MiB of visible data. If you cannot +measure it, reduce the batch size until the EOF stops — after confirming, as above, that the +previous attempt did not commit. + +## Cluster Quotas + +From the [Aurora DSQL quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html). +Unlike the tables above, these are **not verified here** — they need provisioned resources to +exercise, so treat the values as documented rather than observed. + +| Quota | Value | SQLSTATE | Message when exceeded | +| ---------------------------------------------- | ------------------------------ | -------- | --------------------------------------------------------- | +| Tables per database | 1,000 | `54000` | `creating more than 1000 tables not allowed` | +| Views per database | 5,000 | `54000` | `creating more than 5000 views not allowed` | +| Sequences per database | 5,000 | `54000` | `creating more than 5000 sequences is not allowed` | +| Single-Region clusters per account, per Region | 20 (increasable) | API | `You have reached the cluster limit.` | +| Multi-Region clusters per account, per Region | 5 (increasable) | API | `You have reached the cluster limit.` | +| Storage per cluster | 10 TiB, increasable to 256 TiB | `53100` | `Current cluster size exceeds cluster size limit.` | +| Connections per cluster | 10,000 | `53300` | `Unable to accept connection, too many open connections.` | +| New connections per second | 100 sustained, 1,000 burst | `53400` | `Unable to accept connection, rate exceeded.` | +| Concurrent restore jobs | 4 | — | No error code documented | +| CDC streams per cluster | 5 | API | `You have reached the stream limit.` | + +The cluster-count and CDC rows surface as the control-plane API error +`ServiceQuotaExceededException: 402`, not a SQLSTATE, so they never reach a SQL client. diff --git a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md index cc32cd2b..37e00f6a 100644 --- a/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md +++ b/plugins/databases-on-aws/skills/dsql/references/occ-retry-patterns.md @@ -39,7 +39,9 @@ one.** `GRANT` and `REVOKE` raise `OC001` just as `ALTER TABLE` and `CREATE INDE groups them with DDL itself, rejecting a pair with `multiple ddl statements not supported in a transaction` — and it fires whether the change is still in flight or already finished. A migration that only adjusts privileges will raise it, so do not go looking for a `CREATE` or `ALTER` that is -not there. Reads are hit as well as writes, so read paths need the retry loop too. +not there. Reads are hit as well as writes, so read paths need the retry loop too. It arrives on +the statement when the change committed before it, and at `COMMIT` when the change lands +mid-transaction. The [DDL and distributed transactions](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-ddl.html) @@ -59,7 +61,7 @@ the marker before each sleep so an exhausted loop records which cause it was ret An error that arrives as a dropped connection rather than a SQLSTATE — `SSL SYSCALL error: EOF detected` — leaves the commit outcome **unknown**, because the connection closed without a reply. Do not feed it to this loop; follow the confirm-or-stop rule in -[troubleshooting.md](troubleshooting.md#rejections-and-constraint-violations). +[limits-and-error-codes.md](limits-and-error-codes.md#rejections-and-constraint-violations). --- diff --git a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md index 33970455..d3b658ea 100644 --- a/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md +++ b/plugins/databases-on-aws/skills/dsql/references/pg-migrations/index-conversion.md @@ -251,10 +251,8 @@ to be IMMUTABLE anyway. | Max columns per index | 8 | `54011` | `more than 8 column keys in an index are not supported` | | Max PK/index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | -Every index takes one of the 24 slots: the primary key, each `UNIQUE` constraint, and indexes -still building (`indisvalid = f`), so a table with only a PK accepts 23 secondary indexes. For the -key-size budget and the rest of the limit set, see -[troubleshooting.md](../troubleshooting.md#limits-and-error-codes). +For what takes one of the 24 slots, the key-size budget and the rest of the limit set, see +[limits-and-error-codes.md](../limits-and-error-codes.md#exceeded-limits). **Strategy when approaching 24 index limit:** diff --git a/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md b/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md index 0821e7ec..ad8ef0a6 100644 --- a/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md +++ b/plugins/databases-on-aws/skills/dsql/references/troubleshooting.md @@ -18,184 +18,8 @@ Before referring to any listed error, use the routing below and consult ## Limits and Error Codes -**Within this section, match on the message rather than the code.** `54000` and `0A000` each cover -more than a dozen of the rows below, so the code alone does not identify which row you are on. This -is a lookup rule for these tables, not a retry rule — classification elsewhere in the skill is -SQLSTATE-based, and `40001` remains the only retryable code (see -[occ-retry-patterns.md](occ-retry-patterns.md)). - -**MUST** match a stable prefix, not the whole string, and match it case-insensitively — case varies -even within one family (`Datatype limit …` for `char`/`varchar`, `datatype limit …` for `text`). -``, `` and `...` in the Message column are values DSQL interpolates at run time, so an -equality test against the cell never matches. If no message matches, **SHOULD** fall back to the -SQLSTATE class and verify the current limit via `awsknowledge` — message text is not a stable -contract. - -Values marked **observed** were reproduced against a single-Region cluster on 2026-10-01 -(PostgreSQL 16 compatibility). Where an observed value contradicts the -[quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html), -the row says so. Re-verify before a design decision depends on an exact number. - -Run `dsql_lint` to catch unsupported _features_ before execution; use this section for the -_limits_, which are only observable at run time. - -### Exceeded Limits - -| Limit | Value | SQLSTATE | Message | -| ----------------------------------- | ----------------------------------------------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------- | -| Row modifications per transaction | 3,000 | `54000` | `transaction row limit exceeded` | -| Transaction size | 10 MiB, incl. per-row and per-index overhead (see below) | `54000` | `transaction size limit 10mb exceeded`, with `DETAIL: Current transaction size mb > 10mb` | -| Transaction age | 5 minutes | `54000` | `transaction age limit of 300s exceeded`, with `DETAIL: Current transaction age s exceeds 300s` | -| Query temp space | 128 MiB; docs give `53200` and other text | `54000` | `query requires more than allowed temp space of 131072 KB to execute` | -| Extended statistics per table | 5 | `54000` | `more than 5 extended statistics per table are not allowed` | -| Schemas per database | 10, excluding `public` and `sys` | `54000` | `more than 10 schemas not allowed` | -| Indexes per table | 24, counting every index — see below | `54000` | `more than 24 indexes per table are not allowed` | -| Primary key or index key size | ~1,981 bytes observed; docs say 1 KiB | `54000` | `key size too large` | -| Declared key size | 2,000 bytes, rejected at DDL time | `54000` | `key size greater than 2000 bytes not supported` | -| Row size | 2 MiB | `54000` | `maximum row size exceeded` | -| Non-index column size | 1 MiB; docs give the message below, a per-datatype one was seen | `54000` | docs: `maximum column size exceeded`; observed: `datatype limit greater than 1048576 bytes not supported for ` | -| View definition size | 131–524 KiB of SQL text observed, varies by shape; docs say 2 MiB | `54000` | `view definition too large` | -| Expression nesting depth | Reached before any size limit | `54001` | `stack depth limit exceeded` | -| `char` declared size | 4,096 bytes, rejected at DDL time | `54000` | `Datatype limit greater than 4096 bytes not supported for char` | -| `varchar` declared size | 65,535 bytes, rejected at DDL time | `54000` | `Datatype limit greater than 65535 bytes not supported for varchar` | -| `text` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for text` | -| `bytea` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for bytea` | -| `json` / `jsonb` value | 1 MiB stored, rejected at write time | `54000` | `datatype limit greater than 1048576 bytes not supported for jsonb` | -| Columns per index or primary key | 8 | `54011` | `more than 8 column keys in an index are not supported` | -| Columns per table, no primary key | 256 accepted, 257 rejected; docs and message both say 255 | `54011` | `tables can have at most 255 columns` | -| Columns per table, with primary key | 256 accepted, 257 rejected; message names an _index_ | `54011` | `cannot use more than 256 columns in an index` | -| Extended-statistics target | 100 | `22023` | `statistics target exceeds maximum allowed value of 100` | -| `numeric` precision | 1,000 | `22023` | `NUMERIC precision must be between 1 and 1000` | - -Several of these behave in ways the limit alone does not convey: - -- **The per-value 1 MiB limit is a hard bound on high-entropy data.** For random `text`, `base64`, - `bytea` and `jsonb`, 1,048,576 bytes is the largest value accepted and 1,048,577 the first - rejected — nothing fails early. Compressible values go much further: a single `text` column - accepted 214 MiB of one repeated character. Size against raw byte count; compression only ever - buys headroom. -- **The 10 MiB transaction limit also counts index writes.** The check runs at `COMMIT` against an - accounted size that adds a cost per row and per secondary index entry written. The per-entry - cost grows with the index key plus the primary key: small for `int`, `uuid` or `timestamptz` - keys, several times the key width for wide `text` keys. A 500-row batch committed at most - 9.93 MiB of raw data with no secondary index, and 9.11 MiB with four on 100-byte `text` keys. - The row limit is documented as independent of secondary indexes; the size limit is not, and its - accounting is undocumented. Large compressible values, such as JSON over a few KB, can count - below their raw size. To size a batch, read the error: `DETAIL: Current transaction size mb > - 10mb` is the exact accounted size, so one over-limit trial on the real schema gives the ratio to - scale by. -- **The key-size budget is shared, in bytes, by every key column.** One column holds ~1,981 bytes - and each additional key column costs ~7 more, so an 8-column key gets ~241 bytes each. Bytes, - not characters — a 4-byte UTF-8 character key holds ~495 characters. A secondary index key also - carries the table's primary key columns, so a 500-byte primary key leaves ~1,474 bytes for the - index columns. Separately, DDL rejects a key whose _declared_ sizes sum past 2,000 bytes, leaving - the primary key out of that sum for a secondary index; a `varchar(2000)` key therefore passes - `CREATE` and still fails on write above ~1,981 bytes. The quotas page lists the primary key and - secondary index as two separate 1 KiB budgets; the observed behaviour is a single shared budget. -- **The view-definition limit is a 512 KiB internal budget, not a text length.** How much SQL - fits depends on structure: a target list of literals reached ~524 KiB of definition text, an - `IN` list ~229 KiB, a `CASE` chain ~166 KiB, and a chain of OR-ed comparisons only ~131 KiB. The - text length is therefore not a reliable predictor in either direction — a differently-shaped - OR-chain was rejected at ~127 KiB. Treat **~120 KiB** as a working ceiling, but size by trial - rather than by character count when a view approaches it. Deeply nested expressions hit - `54001` first. -- **Every index counts toward the 24**: the primary key, the index behind each `UNIQUE` - constraint, and every `CREATE [UNIQUE] INDEX ASYNC`, including one still building - (`indisvalid = f`). A table with a primary key and one `UNIQUE` constraint has 22 slots left; - a table with no primary key has no hidden index and holds 24. Count with - `SELECT count(*) FROM pg_index WHERE indrelid = 'tbl'::regclass`. `DROP INDEX` frees a slot. -- **`char` and `varchar` limits are enforced on the _declared_ size at DDL time**, so an - oversized declaration fails at `CREATE TABLE`, not on first write. -- **Dropped columns keep counting.** The documented ceiling is 1,600 _cumulative_ columns including - dropped ones, so a table churned by repeated `ADD COLUMN`/`DROP COLUMN` during a migration can - hit `54011` while holding far fewer live columns. Recreate the table rather than continuing to - churn it — see [ddl-migrations/overview.md](ddl-migrations/overview.md). - -### Rejections and Constraint Violations - -Unsupported-feature list: [PostgreSQL compatibility — unsupported features](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html). Index syntax: [CREATE INDEX syntax support](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/create-index-syntax-support.html). - -These are not limits — the feature or the value is refused outright. - -| Cause | SQLSTATE | Message | -| --------------------------------------------------- | -------- | --------------------------------------------------------------------------------- | -| Non-immutable function in an index expression | `42P17` | `functions in index expression must be marked IMMUTABLE` | -| Non-immutable generated-column expression | `42P17` | `generation expression is not immutable` | -| `serial` column type | `42704` | `type "serial" does not exist` | -| Array column type | `0A000` | `datatype text[] not supported` — the declared type is echoed back | -| Sort direction on an index key | `0A000` | `specifying sort order not supported for index keys` | -| Operator class on an index key | `0A000` | `opclass not supported for index keys` | -| Index on an unsupported column type | `0A000` | `datatype is not supported in a key` | -| `CREATE INDEX` without `ASYNC` | `0A000` | `unsupported mode. please use CREATE INDEX ASYNC.` | -| `CREATE INDEX CONCURRENTLY` | `0A000` | `CONCURRENTLY not supported for CREATE INDEX` | -| `ALTER TABLE ADD COLUMN` with any inline constraint | `0A000` | `ALTER TABLE ADD COLUMN with constraint not supported` | -| `ALTER COLUMN ... SET DATA TYPE` | `0A000` | `unsupported ALTER TABLE ALTER COLUMN ... SET DATA TYPE statement` | -| `ALTER COLUMN ... SET STATISTICS`, at any value | `0A000` | `unsupported ALTER TABLE ALTER COLUMN ... SET STATISTICS statement` | -| `CREATE PROCEDURE` | `0A000` | `PROCEDURE is not supported` | -| `CREATE DATABASE` | `0A000` | `unsupported statement: Createdb` | -| Post-creation foreign key without `NOT VALID` | `0A000` | `unsupported ALTER TABLE ADD CONSTRAINT statement` | -| Synchronous `VALIDATE CONSTRAINT` | `0A000` | `unsupported ALTER TABLE VALIDATE CONSTRAINT statement` | -| Foreign key violation — **not retryable** | `23503` | `insert or update on table ... violates foreign key constraint ...` | -| Duplicate against a unique index still building | `23505` | `duplicate key value violates unique constraint ...` | -| Any statement issued after a failure inside `BEGIN` | `25P02` | `current transaction is aborted, commands ignored until end of transaction block` | -| Protocol message over 10 MiB | **none** | No error reaches the client — see below | - -These mislead in practice: - -- **`25P02` masks the real error.** It is the code an agent sees most often, because anything - that keeps issuing statements after a failure inside a transaction block gets it in place of - the original cause. Read the _first_ error in the transaction, not the last. -- **An oversized protocol message closes the connection with no error message.** The client sees - only `SSL SYSCALL error: EOF detected`, which is indistinguishable from a transient network - fault, so a naive retry resends the same oversized statement forever. Treat an EOF on the first - statement after a large one as a size problem, not a network one. Note the AWS documentation - lists `08P01` / `FATAL: invalid message length` for this case; that error is not delivered as - observed here. If you _do_ see `08P01: invalid message length`, that is this case and the - behaviour has changed — trust the error. - -**Any EOF on a statement that could have committed — `COMMIT`, or any write in autocommit mode — -leaves the commit outcome unknown**, whatever the cause, because the -connection closed before the server replied. This applies to the oversized-message case above and -equally to an EOF the agent classifies as a network fault — the size discriminator tells you why the -connection dropped, not whether the write landed. Before resending, an agent **MUST** reconnect and -confirm whether the write committed. Designing the write to be idempotent removes the need for that -check, but only if it was already idempotent — it is not something that can be applied after the -EOF arrives, and a bare retry of a non-idempotent write such as -`UPDATE accounts SET balance = balance - 100` double-applies it. See -[occ-retry-patterns.md](occ-retry-patterns.md#idempotent-transaction-design). - -**If the outcome cannot be confirmed, an agent MUST NOT retry.** A blind -`balance = balance - 100` has no observable trace that distinguishes "applied once" from "not -applied", so there is nothing to query. **MUST** report the unresolved write to the user with the -transaction's identifying values and stop; a duplicated debit is worse than a reported gap. Avoid the situation rather than resolve it: write a -correlation id the transaction itself records, so the question becomes answerable next time. - -The threshold is the _protocol message_ size, which a client does not see directly: a batched or -parameterised multi-row insert can exceed it well below 10 MiB of visible data. If you cannot -measure it, reduce the batch size until the EOF stops — after confirming, as above, that the -previous attempt did not commit. - -### Cluster Quotas - -From the [Aurora DSQL quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html). -Unlike the tables above, these are **not verified here** — they need provisioned resources to -exercise, so treat the values as documented rather than observed. - -| Quota | Value | SQLSTATE | Message when exceeded | -| ---------------------------------------------- | ------------------------------ | -------- | --------------------------------------------------------- | -| Tables per database | 1,000 | `54000` | `creating more than 1000 tables not allowed` | -| Views per database | 5,000 | `54000` | `creating more than 5000 views not allowed` | -| Sequences per database | 5,000 | `54000` | `creating more than 5000 sequences is not allowed` | -| Single-Region clusters per account, per Region | 20 (increasable) | API | `You have reached the cluster limit.` | -| Multi-Region clusters per account, per Region | 5 (increasable) | API | `You have reached the cluster limit.` | -| Storage per cluster | 10 TiB, increasable to 256 TiB | `53100` | `Current cluster size exceeds cluster size limit.` | -| Connections per cluster | 10,000 | `53300` | `Unable to accept connection, too many open connections.` | -| New connections per second | 100 sustained, 1,000 burst | `53400` | `Unable to accept connection, rate exceeded.` | -| Concurrent restore jobs | 4 | — | No error code documented | -| CDC streams per cluster | 5 | API | `You have reached the stream limit.` | - -The cluster-count and CDC rows surface as the control-plane API error -`ServiceQuotaExceededException: 402`, not a SQLSTATE, so they never reach a SQL client. +The limit, rejection and quota tables, with each SQLSTATE and exact message, are in +[limits-and-error-codes.md](limits-and-error-codes.md). ## Connection and Authorization @@ -316,7 +140,7 @@ When migrating from PostgreSQL, remember DSQL doesn't support: - **Partitioning** - Manage data distribution in application See [full list of unsupported features](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/working-with-postgresql-compatibility-unsupported-features.html), -and [Rejections and Constraint Violations](#rejections-and-constraint-violations) for the exact +and [Rejections and Constraint Violations](limits-and-error-codes.md#rejections-and-constraint-violations) for the exact message each rejection produces. ### Error: "datatype text[] not supported" @@ -358,43 +182,27 @@ CREATE INDEX ASYNC idx_logs_created ON logs (created_at); Treat that chunk size as a starting point, not a guarantee. The same transaction is also bounded by size and age, and every secondary index entry written counts toward the size. On a table carrying several secondary indexes that overhead decides the chunk size; see -[Exceeded Limits](#exceeded-limits) for how to size it. +[Exceeded Limits](limits-and-error-codes.md#exceeded-limits) for how to size it. ### Error: "schema has been updated by another transaction (OC001)" -**Cause:** The session's cached copy of the schema catalog is older than a catalog change another -session committed. **One** change is enough, and it does **not** have to be concurrent — a change -that committed before the transaction began still raises it, on the session's next statement -against the changed table. - -**`OC001` is SQLSTATE `40001`** — the same code as an OCC write conflict, and the same code -`SKILL.md` routes into the retry loop. The two causes are distinguished only by the trailing -marker in the message: +**Cause:** The session's cached schema catalog is older than a catalog change another session +committed — any DDL, `GRANT` or `REVOKE`, concurrent or already finished. Reads are hit as well as +writes. It is SQLSTATE `40001`, the same code as an OCC write conflict, told apart only by the +marker: | Message | Marker | Cause | | -------------------------------------------------------- | ------- | ------------------------------------------- | | `change conflicts with another transaction (OC000)` | `OC000` | Concurrent DML on the same rows | | `schema has been updated by another transaction (OC001)` | `OC001` | Any catalog change — DDL, `GRANT`, `REVOKE` | -A single `ALTER TABLE ADD COLUMN`, a single `GRANT` and a single `REVOKE` were each observed to -raise it alone, after the change had already committed. So "my migration runs no DDL" does not rule -it out — though note DSQL itself counts `GRANT` and `REVOKE` as DDL when holding a transaction to a -single DDL statement, rejecting a pair with -`0A000: multiple ddl statements not supported in a transaction`. - -Reads are hit as well as writes, including inside `BEGIN READ ONLY`, so read paths need the same -retry loop. The error arrives on the statement when the change committed before it, and at `COMMIT` -when the change lands mid-transaction. - -Both markers are retryable. A one-shot catalog change clears on the **first** retry, because the -retry refreshes the session's catalog cache; it keeps recurring only while another session keeps -changing the catalog, and then serializing those changes is the fix rather than more backoff. - **Solution:** -1. Retry the transaction — refreshing the catalog cache is usually all it needs -2. Apply the same exponential backoff as any other `40001` -3. If it recurs across retries, serialize the catalog changes rather than widening the backoff +1. Retry the transaction in the same `40001` loop — the retry refreshes the catalog cache, so a + one-shot change clears on the first retry +2. If it recurs across retries, serialize the catalog changes rather than widening the backoff + +See [occ-retry-patterns.md](occ-retry-patterns.md) for the mechanism and the retry code. ## Protocol Compatibility @@ -407,7 +215,7 @@ changing the catalog, and then serializing those changes is the fix rather than A protocol message over 10 MiB is a separate failure with no error message at all — the connection is closed and the client sees only `SSL SYSCALL error: EOF detected`. See -[Rejections and Constraint Violations](#rejections-and-constraint-violations). +[Rejections and Constraint Violations](limits-and-error-codes.md#rejections-and-constraint-violations). ## Additional Resources From e722d325c055410f46f71fed35aaa11f1c1f7693 Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Thu, 8 Oct 2026 13:19:35 -0700 Subject: [PATCH 6/8] docs(dsql): require approval before recreating a churned table The dropped-columns note sent agents to recreate the table, which ends in an irreversible drop. Say it needs user approval, alongside the link to the procedure that carries the full gate. --- .../skills/dsql/references/limits-and-error-codes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md b/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md index 5b17b6f6..43812744 100644 --- a/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md +++ b/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md @@ -89,7 +89,7 @@ Several of these behave in ways the limit alone does not convey: - **Dropped columns keep counting.** The documented ceiling is 1,600 _cumulative_ columns including dropped ones, so a table churned by repeated `ADD COLUMN`/`DROP COLUMN` during a migration can hit `54011` while holding far fewer live columns. Recreate the table rather than continuing to - churn it — see [ddl-migrations/overview.md](ddl-migrations/overview.md). + churn it, with user approval, following [ddl-migrations/overview.md](ddl-migrations/overview.md). ## Rejections and Constraint Violations From 0dfdb4101bfdbc0dfcad07e82c07ed9080aa03a2 Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Thu, 8 Oct 2026 16:07:23 -0700 Subject: [PATCH 7/8] docs(dsql): load the skill for limit questions, act on measured limits For limit and error-code questions agents answered from the AWS docs and never loaded the skill, so the measured limits never reached them. The description now asks for the skill on those questions. Where a measured limit contradicts the quotas page, such as key size, the reference now says to size against the measured value and mention the documented one, instead of re-verifying against the docs. --- plugins/databases-on-aws/skills/dsql/SKILL.md | 4 ++-- .../skills/dsql/references/limits-and-error-codes.md | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/plugins/databases-on-aws/skills/dsql/SKILL.md b/plugins/databases-on-aws/skills/dsql/SKILL.md index c5a86c4d..0a5845af 100644 --- a/plugins/databases-on-aws/skills/dsql/SKILL.md +++ b/plugins/databases-on-aws/skills/dsql/SKILL.md @@ -1,6 +1,6 @@ --- name: dsql -description: "Build with Aurora DSQL — manage schemas, execute queries, handle migrations, diagnose query plans, diagnose cluster performance, load data, and develop applications with a serverless, distributed SQL database. Covers IAM auth, multi-tenant patterns, MySQL-to-DSQL and PostgreSQL-to-DSQL schema conversion, foreign key constraints, OCC retry patterns, ORM migration (Django/EF Core/Hibernate/Rails/SQLAlchemy), DDL operations, query plan explainability, system diagnostics via CloudWatch AAS, SQL compatibility validation, and bulk data loading. Triggers on phrases like: DSQL, Aurora DSQL, distributed SQL database, serverless PostgreSQL-compatible database, migrate to DSQL, DSQL query plan, DSQL EXPLAIN ANALYZE, DSQL ENUM, DSQL foreign key, DSQL OCC retry, DSQL multi-region, DSQL JSONB, DSQL GIN index, load into DSQL, load CSV into DSQL, bulk load DSQL, aurora-dsql-loader, DSQL slow, DSQL performance, DSQL wait events, DSQL AAS." +description: "Build with Aurora DSQL — manage schemas, execute queries, handle migrations, diagnose query plans, diagnose cluster performance, load data, and develop applications with a serverless, distributed SQL database. Covers IAM auth, multi-tenant patterns, MySQL-to-DSQL and PostgreSQL-to-DSQL schema conversion, foreign key constraints, OCC retry patterns, ORM migration (Django/EF Core/Hibernate/Rails/SQLAlchemy), DDL operations, query plan explainability, system diagnostics via CloudWatch AAS, SQL compatibility validation, and bulk data loading. Load before answering DSQL limit or error-code questions; it has limits docs omit. Triggers on phrases like: DSQL, Aurora DSQL, distributed SQL database, serverless PostgreSQL-compatible database, migrate to DSQL, DSQL query plan, DSQL EXPLAIN ANALYZE, DSQL ENUM, DSQL foreign key, DSQL OCC retry, DSQL multi-region, DSQL JSONB, DSQL GIN index, load into DSQL, load CSV into DSQL, bulk load DSQL, aurora-dsql-loader, DSQL slow, DSQL performance, DSQL wait events, DSQL AAS." license: Apache-2.0 metadata: tags: aws, aurora, dsql, distributed-sql, distributed, distributed-database, database, serverless, serverless-database, postgresql, postgres, sql, schema, migration, multi-tenant, iam-auth, aurora-dsql, mcp, orm, enum, foreign-key, occ-retry, django, ef-core, dotnet, csharp, hibernate, rails, multi-region, schema-conversion, type-mapping, data-loading, system-diagnostics, wait-events, aas, performance, cloudwatch @@ -159,7 +159,7 @@ defaults that may change — when a user's decision depends on an exact limit, v | IDENTITY/SEQUENCE CACHE values | 1 or >= 65536 | `aurora dsql sequence cache` | | Supported column data types | See docs | `aurora dsql supported data types` | -**When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. For the full limit set with each SQLSTATE and its exact error message, see [limits-and-error-codes.md](references/limits-and-error-codes.md). +**When to verify:** Before recommending batch sizes, connection pool settings, or schema designs where hitting a limit would cause failures; any time the exact number can affect user decision. For the full limit set with each SQLSTATE and its exact error message, see [limits-and-error-codes.md](references/limits-and-error-codes.md); where it marks a value observed and the docs differ, use the observed value. **Fallback:** If `awsknowledge` is unavailable, use the defaults above and flag that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). diff --git a/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md b/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md index 43812744..85b80311 100644 --- a/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md +++ b/plugins/databases-on-aws/skills/dsql/references/limits-and-error-codes.md @@ -20,7 +20,9 @@ contract. Values marked **observed** were measured on a single-Region cluster (PostgreSQL 16 compatibility) rather than taken from the documentation. Where an observed value contradicts the [quotas documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/CHAP_quotas.html), -the row says so. Re-verify before a design decision depends on an exact number. +the row says so. **SHOULD** size against the observed value, which is what DSQL enforced when +measured, and tell the user the documented value differs. To re-check one before a design decision +depends on it, test it on a cluster; the quotas page is what it already contradicts. Run `dsql_lint` to catch unsupported _features_ before execution; use this section for the _limits_, which are only observable at run time. @@ -75,7 +77,8 @@ Several of these behave in ways the limit alone does not convey: index columns. Separately, DDL rejects a key whose _declared_ sizes sum past 2,000 bytes, leaving the primary key out of that sum for a secondary index; a `varchar(2000)` key therefore passes `CREATE` and still fails on write above ~1,981 bytes. The quotas page lists the primary key and - secondary index as two separate 1 KiB budgets; the observed behaviour is a single shared budget. + secondary index as two separate 1 KiB budgets, but DSQL enforces the single shared budget above: + give the user these figures, and note that the documentation says 1 KiB. - **The view-definition limit is a 512 KiB internal budget, not a text length.** How much SQL fits depends on the query's structure, so treat **~120 KiB** of definition text as a working ceiling and size by trial when a view approaches it. From c86f1bcdd01f5b2519d72ee4262512fda0e278e2 Mon Sep 17 00:00:00 2001 From: Prabakaran Annadurai Date: Thu, 8 Oct 2026 16:53:36 -0700 Subject: [PATCH 8/8] docs(dsql): cut SKILL.md error bullets to pointers The OCC and transaction-size bullets in Error Scenarios restated the references. They now route to occ-retry-patterns.md and limits-and-error-codes.md, keeping only "keep OC001 in the retry loop" and "size is checked at COMMIT"; eval 24 dropped the COMMIT fact without it. Eval 26 expectation 2 required "not two statements overlapping in time", which fails a correct answer: the prompt fails at COMMIT, and OC001 surfaces at COMMIT when the change lands mid-transaction. It now accepts a GRANT that finished before the transaction or one that committed during it. The expected_output and README row match. --- plugins/databases-on-aws/skills/dsql/SKILL.md | 4 ++-- tools/evals/databases-on-aws/README.md | 2 +- tools/evals/databases-on-aws/dsql/evals.json | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/databases-on-aws/skills/dsql/SKILL.md b/plugins/databases-on-aws/skills/dsql/SKILL.md index 0a5845af..6feea7fa 100644 --- a/plugins/databases-on-aws/skills/dsql/SKILL.md +++ b/plugins/databases-on-aws/skills/dsql/SKILL.md @@ -288,9 +288,9 @@ MUST load [system-diagnostics/workflow.md](references/system-diagnostics/workflo - **`awsknowledge` returns no results:** Use the default limits in the table above and note that limits should be verified against [DSQL documentation](https://docs.aws.amazon.com/aurora-dsql/latest/userguide/). - **`dsql_lint` unavailable or timing out:** See the Error Handling section of [dsql-lint.md](references/dsql-lint.md). Do not silently skip validation — inform the user and require explicit confirmation before proceeding with manual rules from [development-guide.md](references/development-guide.md). -- **OCC serialization error:** Retry the transaction; if persistent, check for hot-key contention. `(OC001)` in the message means the session's cached schema catalog is older than a committed catalog change — any DDL or `GRANT`/`REVOKE`, even one finished before the transaction began — not row contention or overlapping timing, and reads are hit too. Keep it in the retry loop: a retry refreshes the cache, so a one-shot change clears on the first retry; if it persists, you **MUST** serialize the catalog changes. Match `(OC001)` as a substring — see [occ-retry-patterns.md](references/occ-retry-patterns.md). +- **OCC serialization error (`40001`):** Retry with backoff, and keep `(OC001)` in the loop too — see [occ-retry-patterns.md](references/occ-retry-patterns.md). - **Foreign key violation (`23503`):** Correct the relationship or referential action; **MUST NOT** send it through the `40001` retry loop — see [troubleshooting.md](references/troubleshooting.md). -- **Transaction exceeds limits:** Split write batches to stay under 3,000 row modifications and 10 MiB. The 10 MiB is checked at `COMMIT`, and secondary index entries and locked-row primary keys count toward it, so raw data size does not predict it — size from the `54000` DETAIL (see [limits-and-error-codes.md](references/limits-and-error-codes.md#exceeded-limits)), batching pattern in [batched-migration.md](references/ddl-migrations/batched-migration.md). +- **Transaction exceeds limits:** Split batches under 3,000 rows and 10 MiB; size is checked at `COMMIT` — see [limits-and-error-codes.md](references/limits-and-error-codes.md#exceeded-limits) to size them and [batched-migration.md](references/ddl-migrations/batched-migration.md) for the pattern. - **Token expiration mid-operation:** Generate a fresh IAM token — see [authentication-guide.md](references/auth/authentication-guide.md). See [troubleshooting.md](references/troubleshooting.md) for other issues. ## Additional Resources diff --git a/tools/evals/databases-on-aws/README.md b/tools/evals/databases-on-aws/README.md index b2c9d86e..0d67661d 100644 --- a/tools/evals/databases-on-aws/README.md +++ b/tools/evals/databases-on-aws/README.md @@ -125,7 +125,7 @@ mise exec -- python tools/evals/databases-on-aws/dsql/scripts/run_functional_eva | 23. Index key sort order | Index conversion | LLM judge | Strips `ASC`/`DESC`, keeps `ASYNC`, explains the backward scan serves a uniform `ORDER BY` but not a mixed one, no counter-based lint gating, retains `NULLS` placement | | 24. Transaction size exceeded | Troubleshooting | LLM judge | Identifies the 10 MiB limit, checked at `COMMIT` and not against raw bytes, splits the batch, also cites the 3,000-row limit, does not suggest raising it | | 25. EOF at COMMIT | Troubleshooting | LLM judge | Commit outcome unknown, bare retry can double-debit, reconnect and confirm, addresses the unconfirmable case, not an ordinary `40001` retry | -| 26. Catalog change (OC001) | OCC retry | LLM judge | `GRANT`/`REVOKE` raise `OC001` like DDL, the session's cached catalog is stale rather than overlapping the change, retry clears a one-shot, serialize if persistent, stays in the `40001` loop | +| 26. Catalog change (OC001) | OCC retry | LLM judge | `GRANT`/`REVOKE` raise `OC001` like DDL, the session's cached catalog is stale, change before or during the job, retry clears a one-shot, serialize if persistent, stays in the `40001` loop | | 27. Key size too large | Limits | LLM judge | Budget combined across key columns, ~1,981 bytes for one column, secondary index shares the primary key's budget, bytes not characters, shorten or hash | | 28. Index count on migration | Limits | LLM judge | Counts the PK and `UNIQUE` indexes inside the 24, concludes 25 does not fit, never 24 secondary plus the PK, consolidates rather than raising | diff --git a/tools/evals/databases-on-aws/dsql/evals.json b/tools/evals/databases-on-aws/dsql/evals.json index bc41faf6..fcaaaaa4 100644 --- a/tools/evals/databases-on-aws/dsql/evals.json +++ b/tools/evals/databases-on-aws/dsql/evals.json @@ -319,10 +319,10 @@ { "id": 26, "prompt": "My DSQL migration only runs GRANT SELECT statements, no DDL. A long-running job that reads a large table and writes summary rows back keeps failing at COMMIT with 'schema has been updated by another transaction (OC001)' and SQLSTATE 40001. There's no DDL anywhere, so why?", - "expected_output": "Explains that GRANT and REVOKE are catalog changes that raise OC001 just as DDL does, that the trigger is the session's stale cached catalog rather than concurrency, and that a retry clears a one-shot change", + "expected_output": "Explains that GRANT and REVOKE are catalog changes that raise OC001 just as DDL does, that the trigger is the session's stale cached catalog rather than row contention, and that a retry clears a one-shot change", "expectations": [ "States that GRANT and REVOKE modify the schema catalog and raise OC001 just as DDL statements do, so the absence of DDL does not rule it out", - "Explains that the cause is the session's cached schema catalog being older than a committed catalog change, which raises OC001 even when the change completed before the transaction started, not two statements overlapping in time", + "Explains that the cause is the session's cached schema catalog being older than a committed catalog change, and accepts either case as correct: a GRANT that completed before the transaction started, or one that committed during it", "States that the reporting transaction should retry, because a retry refreshes the session's catalog cache and a one-shot catalog change clears", "Advises serializing the catalog changes if the error persists across retries", "Does not advise excluding OC001 from the 40001 retry loop or treating it as non-retryable"