Skip to content

Answer throttled metadata requests from Glue in the cursor - #803

Merged
laughingman7743 merged 25 commits into
masterfrom
feat/786-glue-metadata-fallback
Sep 25, 2026
Merged

laughingman7743 merged 25 commits into
masterfrom
feat/786-glue-metadata-fallback

Conversation

@laughingman7743

@laughingman7743 laughingman7743 commented Sep 23, 2026 •

Copy link
Copy Markdown
Member

WHAT

The cursor answers a throttled Athena metadata request from the AWS Glue Data Catalog in Glue-backed catalogs.

Cursor method Athena API Glue fallback IAM action
get_table_metadata() GetTableMetadata GetTable glue:GetTable
list_table_metadata() ListTableMetadata GetTables glue:GetTables
list_databases() ListDatabases GetDatabases glue:GetDatabases

Catalogs: AwsDataCatalog (no CatalogId) and S3 Tables catalogs (s3tablescatalog/<table-bucket>, passed as CatalogId). Other catalogs are unchanged.

Flow (BaseCursor._with_glue_fallback(), with an async counterpart in AioBaseCursor):

  1. The Athena request runs with the cursor's retry policy. retry_api_call(..., stop_on=...) (a new keyword-only argument, also on aio.util.async_retry_api_call) stops it at the first throttled response, using util._is_throttling_error. That includes Glue throttling that Athena wraps in a MetadataException. Other errors keep their retries.
  2. On throttling, the request goes to Glue and a warning is logged.
  3. For get_table_metadata(), Glue's EntityNotFoundException is raised as OperationalError, which is the shape Athena's would have. For listings, the request returns to Athena instead.
  4. Any other Glue failure logs a second warning. The Athena request then continues with the retry policy, and a listing resumes at the throttled page.

pyathena/glue.py GlueMetadataClient: the connection holds one as a private _glue. It:

  • builds the boto3 Glue client on first use, under its own lock, from the connection's session, region and botocore config, without Athena's endpoint_url or api_version
  • addresses the catalog and runs GetTable / GetTables / GetDatabases, converting page by page
  • stops being used for the connection after a request that cannot connect to Glue or finds no Glue endpoint (botocore ConnectionError, BaseEndpointResolverError). A read timeout or a dropped connection does not stop it.
  • flattens a Glue table into the AthenaTableMetadata that Athena reports:
    • location, inputformat and outputformat are always set
    • serde.serialization.lib is set whenever SerdeInfo is present
    • SerDe parameters get the serde.param. prefix
    • the Glue Description is not used as the comment
    • Iceberg columns marked iceberg.field.current=false are left out

retry_api_call / async_retry_api_call: gain a keyword-only stop_on predicate. An exception it accepts is raised at once instead of retried. Existing callers that pass **request built from a single-key literal now annotate it as dict[str, Any], in common.py, aio/common.py, spark/common.py and filesystem/s3.py. Without that, mypy would match its str values against stop_on. The values are unchanged. Two effects on outside callers: code that passes **dict[str, str] gets the same mypy error, and a wrapped function that takes its own stop_on keyword no longer receives it, because retry_api_call now consumes that name. AWS request parameters are CamelCase, so no call in PyAthena is affected.

Option: glue_metadata_fallback defaults to True. It is a new connection option and a SQLAlchemy URL query parameter. The async cursor sends the Glue request, client construction included, in a worker thread.

SQLAlchemy: the dialect has no Glue code; it gets the fallback through the cursor. In Glue-backed catalogs, Glue answers throttled requests for table comments, options, columns, has_table(), and table, view and schema names. The information_schema fallback remains for other catalogs, for a failed Glue request, and when the option is off.

Docs: docs/usage.md gains a "Table and database metadata" section, and docs/sqlalchemy.md links to it.

WHY

Closes #786. B-1 of that issue shipped in #801; this PR covers the rest: throttling tolerance for table comments and options, and for table, view and schema listings (B-2). The fallback lives in the cursor because the metadata limit is per account: any cursor can be throttled, not only SQLAlchemy reflection, and SQLAlchemy inherits the fallback.

The approach was chosen from live measurements (2026-09-23/24, us-west-2).

SHOW CREATE TABLE cannot reproduce get_table_options().

  • tblproperties differ for every table kind.
  • serdeproperties lack serialization.format.
  • ROW FORMAT DELIMITED hides the SerDe class.
  • S3 Tables DDL has no LOCATION.
  • Keys with quotes are not escaped.

Glue GetTable, flattened as above, equals GetTableMetadata field for field. The fields compared were name, type, columns, partition keys, times, comment and all options. The 12 table kinds were:

  • Hive TEXTFILE, JSON, Parquet and ORC
  • a partitioned table
  • Glue Iceberg
  • a view
  • two tables created through the Glue API, one with Description
  • S3 Tables

GetTables also matched ListTableMetadata for the same tables.

Listings match.

  • GetDatabases and ListDatabases return the same 1,857 databases.
  • GetTables and ListTableMetadata return the same names and types in the sampled Glue schemas and in S3 Tables.
  • S3 Tables accept CatalogId="s3tablescatalog/<bucket>" without the account ID; the bucket name alone fails.

After the review, also measured:

  • Iceberg after DROP COLUMN and CHANGE COLUMN: Glue keeps the old columns marked not current, and the fix leaves them out.
  • A same-account table resource link: equal field for field.
  • Mixed-case table names: Glue resolves them as Athena does.
  • Expression: five patterns gave identical results.

Glue is rate-limited separately. With SDK retries off:

  • GetTableMetadata at 30 threads ran at about 155 calls/s for about 13 s, then returned 100% ThrottlingException for 45 s.
  • During that episode, glue:GetTable at 5 threads succeeded 1,236 of 1,236 times.
  • Glue alone at 30 threads succeeded 5,117 of 5,117 times (about 184 calls/s), with no throttling.
  • These figures cover one table in one region, and Glue's own limit was not reached.

Release note

get_table_metadata(), list_table_metadata() and list_databases() — and SQLAlchemy reflection through them — now answer a throttled request from the AWS Glue Data Catalog in AwsDataCatalog and S3 Tables catalogs. The fallback needs glue:GetTable, glue:GetTables and glue:GetDatabases. Without them, the Athena request is retried as before, after one failed Glue call and a warning. Pass glue_metadata_fallback=False to connect(), or glue_metadata_fallback=false in a SQLAlchemy URL, to turn it off. retry_api_call() and async_retry_api_call() accept a keyword-only stop_on predicate. Code that type-checks calls passing **dict[str, str] may need to annotate the dict as dict[str, Any], and a wrapped function's own stop_on keyword is no longer passed through.

TEST

Tested commit: 0416eb4 (rebased to 7bbf128) for the latest live run of the 62 targeted tests. The later commits add docstrings only; test_util.py and the offline test_glue.py cases pass on them. Earlier runs are listed below. a283df4 adds tests/pyathena/test_util.py cases only, which were run offline.

Lint and docs: just lint passed and just docs lint reported 0 errors on a283df4. A sphinx-build of this tree (at ba564e7) gave no warnings for the changed files.

Live runs, started only after CI was idle:

  • uv run --env-file .env pytest -n 1 tests/pyathena/test_glue.py tests/pyathena/test_cursor.py tests/pyathena/aio/test_cursor.py tests/pyathena/sqlalchemy/test_base.py tests/pyathena/aio/sqlalchemy/test_base.py -k "Glue or glue or throttled or table_level_reflection or unrecognized_metadata or table_metadata or list_databases or conn_str or metadata_exception or unreachable or missing or resumes": 62 passed.
  • pytest -n 1 tests/sqlalchemy/test_suite.py -k HasTableTest: 15 passed and 2 skipped, both sync and with --dburi async, on 99435e8.

Offline run: tests/pyathena/test_util.py: 43 passed.

How the tests reach AWS. Only Athena's throttling is injected, through tests/pyathena/util.throttle_metadata_api. Every Glue call is real:

  • tests/pyathena/test_glue.py::TestGlueMetadataClient:
    • GetTable, GetTables and GetDatabases compared field for field with Athena for Hive, partitioned and view tables and for S3 Tables
    • a missing table (EntityNotFoundException)
    • an unsupported catalog
    • an unreachable Glue: requests sent through a proxy port nothing listens on, so no DNS assumptions
    • a request botocore rejects (ParamValidationError), which keeps Glue in use
    • the client built once while the lock is held, without Athena's endpoint_url or api_version
    • the flattening rules, using the Glue responses measured for this PR
  • Cursor tests:
    • throttled reads compared with the API
    • wrapped throttling
    • a missing table answered by real Glue
    • a missing database in a listing returning to Athena
    • an unreachable Glue not tried again
    • a MetadataException in the retry policy
    • a listing resuming at the throttled page
    • the fallback disabled, and a catalog outside Glue
    • async: the client and request running off the event loop, checked with spies around the real methods
  • tests/pyathena/test_util.py: _is_throttling_error, _without_retries, and retry_api_call's stop_on.
  • Dialect: live comparisons, sync and async, S3 Tables, and the URL option.

Vacuity:

  • Removing the reachability marking fails 2 tests.
  • Marking every BotoCoreError unreachable fails the ParamValidationError test.
  • Restoring pyathena/common.py and pyathena/aio/common.py from origin/master fails 9 of 12 cursor tests. The 3 that pass are guards: fallback disabled, a catalog outside Glue, and a MetadataException the policy retries.
  • In test_util.py, dropping the MetadataException unwrap, ignoring stop_on, or keeping the removed codes each fails its test.

CI: earlier heads are covered in the review records. The current head's CI is pending.

Not verified:

  • IAM and Lake Formation parity.
  • IAM Identity Center workgroups. The docs advise turning the fallback off there.
  • Cross-account resource links.
  • GLUE-type catalogs registered with other names.
  • A boto3 Session shared across threads while other clients are built from it (for example S3 result sets).

🤖 Generated with Claude Code

@laughingman7743 laughingman7743 changed the title Answer throttled SQLAlchemy metadata requests from Glue Answer throttled metadata requests from Glue in the cursor Sep 23, 2026
@laughingman7743
laughingman7743 force-pushed the feat/786-glue-metadata-fallback branch from ec384d2 to ad0eca2 Compare September 23, 2026 22:00
Comment thread pyathena/common.py
f"Glue request to {description} failed: {e}; retrying the Athena request{suffix}."
)

def _with_glue_fallback(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one (behavior and implementation): FINDINGS, repaired

Base 56c34e520b3317e89a07db825e3f63d1eceaf19b, head ba564e7ee36fd79468b7b572a3084594bb8de804.

Covered:

  • pyathena/glue.py
  • BaseCursor and AioBaseCursor metadata methods and the fallback flow
  • Connection.glue_client and glue_metadata_fallback
  • util._retry_api_call / _async_retry_api_call / _without_retries
  • the dialect's use through the cursor, _get_columns included
  • tests and docs

Passes: /code-review (high) twice, one on the dialect-level predecessor and one on the cursor-level head, then /simplify (reuse, simplification, efficiency, altitude).

Repaired:

  • Glue throttling wrapped in a MetadataException was neither retried nor sent to Glue. The throttle check now unwraps it.
  • With MetadataException in the retry policy, the first attempt ran the whole ladder before Glue. A later fix retried twice, which HasTableTest::test_metadata_errors_do_not_establish_absence[InternalServerException] caught. Both are gone: pyathena/common.py:369 keeps the policy and only stops at throttling, through stop_on.
  • An unreachable Glue endpoint cost a connect timeout on every throttled request. A BotoCoreError now turns the fallback off for the connection.
  • The async cursor built the Glue client on the event loop. It is now built in the worker thread.
  • A listing whose Glue request failed started again from page 1. It now resumes at the throttled page, which test_listing_resumes_after_a_failed_glue_request covers.
  • Glue kept Iceberg columns marked iceberg.field.current=false. They are now left out; this was measured after DROP COLUMN and CHANGE COLUMN.
  • Glue EntityNotFoundException from a listing was raised as final, which would have broken the dialect's S3 Tables [] for a missing catalog. It is now final only for get_table_metadata().
  • The throttling test helper was copied four times, and two tests branched on their own parameters.

Rejected, with evidence:

  • "Athena never reports None for inputformat": the first probe showed "inputformat": null for Iceberg.
  • "Resource links differ": a same-account link measured equal.
  • "Mixed-case names miss in Glue": measured equal.

Deferred:

  • A shared boto3 Session used by several threads to build clients. S3 clients are already built the same way.
  • A Glue AccessDeniedException does not turn the fallback off. Lake Formation grants can be per table.
  • The S3 Tables prefix constant stays duplicated. Importing it from the dialect would invert the dependency.
  • The cursor sets the connection's _glue_unreachable flag directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rebase onto 82e88f4 (#805)

The branch was rebased from merge-base 56c34e5 onto 82e88f4, which adds MIT headers to existing files (#805). The head moved from 2e4d7eca296da5c1749c4fdfc3175853742a1243 to c277aa0118f7af6e8b24b86476067e2f43f42743. There were no conflicts.

git range-diff 56c34e5..2e4d7ec origin/master..c277aa0 shows:

The patch content is unchanged. #805 changes no behavior or contract this PR relies on.

Correction to earlier reports: Benchmark tooling / offline failed on every head from ad0eca2 to 2e4d7ec. I had checked only the Test workflow.

just lint passes on c277aa0.

Comment thread docs/usage.md

(usage-table-metadata)=

## Table and database metadata

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two (claims, callers, operations): CLEAN after one correction

Base 56c34e520b3317e89a07db825e3f63d1eceaf19b, head ba564e7ee36fd79468b7b572a3084594bb8de804.

Claims checked against code or measurement:

  • docs/usage.md:666 section:
    • the per-method IAM actions: GlueMetadataCatalog calls get_table, get_tables and get_databases only
    • "first throttled response": stop_on
    • wrapped throttling
    • EntityNotFound final only for get_table_metadata()
    • unreachable Glue turns the fallback off
    • no workgroup on the Glue request
    • one client, without endpoint_url: test_glue_client_leaves_out_athena_endpoint
  • PR body measurements: the probes from 2026-09-23/24 are listed in WHY.

Correction: the docs said an unreachable Glue "turns the fallback off" without saying that the first such request waits out botocore's connect timeout and retries. ba564e7 states it.

Existing callers:

  • The public signatures of get_table_metadata(), list_table_metadata(), list_databases() and retry_api_call() are unchanged. Adding stop_on to the public retry_api_call broke mypy for 10 **request callers, so it went to a private _retry_api_call.
  • The private _get_table_metadata, _list_table_metadata and _list_databases gained optional keywords only.
  • New public surface:
    • Connection(glue_metadata_fallback=True) and Connection.glue_client
    • the SQLAlchemy URL parameter
    • pyathena.glue

AWS operator:

  • A request that is not throttled is unchanged: one Athena call with the same policy.
  • A throttled request adds one Glue call. If Glue fails, it adds one failed Glue call and a warning, then the Athena ladder runs as before.
  • The fallback is default on, and the release note and docs name the IAM actions.
  • IAM Identity Center workgroups, Lake Formation parity and cross-account links are not measured. The docs advise turning the fallback off where access depends on the workgroup.

Evidence:

  • Local runs (56 targeted, plus HasTableTest sync and async) ran after CI was idle.
  • A run concurrent with another PR had no metadata-throttling failures. Its one query-side internal error is tracked in Transient query execution failures when CI runs overlap #804.
  • Green CI logs do not show whether Glue actually answered; passing jobs print no fallback warnings.

Comment thread tests/pyathena/test_cursor.py Outdated

assert calls == ["get_table_metadata"]

def test_glue_client_leaves_out_athena_endpoint(self):

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review (relayed): FINDINGS (tests only)

Reviewer: Codex CLI 0.156.0, model gpt-6-sol, codex exec --sandbox read-only, session 01a0d083-d52a-74f2-9a37-efdddf1eb01f.

  • This was a static review of a detached snapshot, with no tests, GitHub or network. The reviewer did not see the PR text or the author's conclusions.
  • Base 56c34e520b3317e89a07db825e3f63d1eceaf19b, head ba564e7ee36fd79468b7b572a3084594bb8de804. The snapshot was unchanged afterwards.

Covered:

  • the full diff
  • sync and async fallback and retries
  • Glue mapping and pagination
  • connection client creation
  • SQLAlchemy reflection paths
  • the new tests
  • both docs

Quoted: "I found no source-level fallback, pagination-resume, exception-shape, or documentation mismatch in the reviewed paths."

Findings (both Low, quoted):

  1. "tests/pyathena/aio/test_cursor.py:493: The async Glue test asserts returned metadata but never checks the executing thread. Moving Glue client creation and requests onto the event-loop thread would leave this test passing while making metadata calls block the loop."
  2. "tests/pyathena/test_cursor.py:1565: The client test constructs glue_client sequentially and supplies no api_version. It cannot catch concurrent creation of multiple clients or accidental forwarding of Athena's api_version."

Author verification: both confirmed. Finding 1 is anchored here as well because the aio test line was not a changed line at that position.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair and independent follow-up: CLEAN at 2e4d7eca296da5c1749c4fdfc3175853742a1243

Repairs (tests only; the source is unchanged since ba564e7):

  1. Async thread check. tests/pyathena/aio/test_cursor.py::test_glue_request_runs_off_the_event_loop records which thread builds the Glue client and which sends the request. Both must differ from the event-loop thread.

  2. Client test. tests/pyathena/test_cursor.py::test_glue_client_leaves_out_athena_endpoint now:

    • passes Athena's api_version
    • has eight threads access glue_client behind a barrier
    • asserts that the client is created once and while _glue_client_lock is held

    The first two attempts at this test relied on timing, which the follow-ups showed cannot prove a lock is used. The final version asserts that the lock is held instead.

Self-review of the repairs:

  • Behavior: no source change.
  • Vacuity:
    • Moving the Glue call onto the loop fails the async test (run against a live cursor).
    • Dropping api_version from the filter fails the client test.
    • Replacing the lock with contextlib.nullcontext() fails it too, 3 runs of 3.
    • With the lock, it passed 3 of 3 runs, offline.
  • just lint passes.

Independent follow-ups (Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, a detached snapshot per head, range diff from ba564e7):

Session Head Result
01a0d0ad-2346-73a2-aa18-f3cd50e5fda0 b53a64e async check covered; FINDINGS: the concurrency test did not ensure simultaneous first access
01a0d0ae-b92f-7a53-a3b9-4aa026b5ef96 7abaf47 FINDINGS: a fixed sleep does not guarantee overlap
01a0d0b0-64a7-7893-978b-2d05cbcb0278 a7d3bde FINDINGS: the inner barrier timeout still depends on scheduling
01a0d0b1-f7b0-78e2-b204-459ab834bf67 2e4d7ec CLEAN

All of these are static reviews.

@laughingman7743
laughingman7743 force-pushed the feat/786-glue-metadata-fallback branch from 2e4d7ec to c277aa0 Compare September 24, 2026 00:25
@laughingman7743
laughingman7743 marked this pull request as ready for review September 24, 2026 01:28
@laughingman7743
laughingman7743 marked this pull request as draft September 24, 2026 12:27
Comment thread pyathena/glue.py
from botocore.config import Config


class GlueMetadataClient:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round one, redesign (behavior and implementation): FINDINGS, repaired

Base 82e88f4ebbd89a4dad8b3b57ac412012601f0c45, head a283df41193f379f66aeb556f1efa89e450e036c.

Scope: the maintainer asked for this redesign after Ready. GlueMetadataClient now owns the Glue client, its lock and its reachability, and Connection.glue_client is no longer public. The maintainer also asked for Glue tests without mocks. The range c277aa0..a283df4 was reviewed in full.

Passes:

  • /code-review (high) on a frozen snapshot at 02b4535
  • /simplify on the four angles: reuse, simplification, efficiency, altitude

Repaired:

  • HTTPClientError (read timeout, dropped connection) meant Glue had been reached, yet it turned the fallback off. Only botocore ConnectionError and BaseEndpointResolverError do now.
  • NoCredentialsError and NoRegionError were listed as unreachable, but they fail on the Athena request first. They were removed, along with the docs sentence.
  • The failure log read the shared flag, so another thread's failure could mislabel it. It now classifies the exception itself.
  • _request dispatched on an operation string. Each method now calls the client directly inside _tracking_reachability().
  • Listings held every raw page. They now convert page by page.
  • The public methods lacked Google-style docstrings.
  • ConnectionError shadowed the builtin; it is now imported as BotoConnectionError.
  • The unreachable-Glue test relied on DNS NXDOMAIN and legacy retries. It now uses a closed proxy port with standard retries and one attempt, and the builder is shared in tests/pyathena/util.py.
  • util.py gained tests for _is_throttling_error, _without_retries and stop_on (maintainer request).

Deferred:

  • The cursor classifies with GlueMetadataClient.UNREACHABLE_ERRORS rather than reading the flag. This is the fix for the cross-thread log finding above.
  • The sync and async _with_glue_fallback still duplicate each other, following the repository's existing sync/async pattern.
  • The method docstrings stay in full Google style, as the repository convention asks.
  • The shared-Session race with S3 result sets predates this PR.

Comment thread docs/usage.md

Glue's report that the table does not exist, for `get_table_metadata()`, raises `OperationalError`, as Athena's does.
If the Glue request fails for any other reason, for example for lack of permission or because the Glue endpoint cannot be reached, a second warning is logged and the Athena request runs again with the retry policy.
A request that cannot connect to Glue, such as from a network with an Athena VPC endpoint but no route to Glue, or that finds no Glue endpoint for the connection's region and endpoint options, also turns the fallback off for the rest of that connection; a request that cannot connect first waits out the botocore connect timeout and retries of the connection's `config`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review round two, redesign (claims, callers, operations): CLEAN

Base 82e88f4ebbd89a4dad8b3b57ac412012601f0c45, head a283df41193f379f66aeb556f1efa89e450e036c.

Claims checked against the code:

  • The docs/usage.md section: the method table and IAM actions (GlueMetadataClient calls only get_table, get_tables and get_databases), the first throttled response, the wrapped throttling, the final absence only for get_table_metadata(), the fallback off after a connection or endpoint-resolution failure and the connect-timeout wait before it, the client built once without endpoint_url, and the workgroup note.
  • The PR body was rewritten for the redesign.

Callers:

  • Connection.glue_client, public in earlier heads of this PR, is gone before any release.
  • New public surface: glue_metadata_fallback and pyathena.glue.GlueMetadataClient.
  • The public signatures of retry_api_call, get_table_metadata, list_table_metadata and list_databases are unchanged.

Operations:

  • A request that is not throttled makes one Athena call, as before.
  • A throttled request adds one Glue call.
  • A request that cannot connect to Glue waits out the connect timeout once per connection, then skips Glue.
  • A transient read timeout keeps Glue in use.

Evidence:

  • Live runs were made after CI was idle, and only Athena throttling is injected.
  • The vacuity checks are in the PR body.
  • Green CI logs do not show whether Glue answered, since passing jobs print no fallback warnings.

calls = throttle_metadata_api(cursor.connection.client, monkeypatch)

# Glue reports each table as Athena does, and nothing is retried first.
assert read() == expected

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Independent review of the redesign (relayed): FINDINGS (test environment only)

Reviewer and scope:

  • Codex CLI 0.156.0, model gpt-6-sol, codex exec --sandbox read-only, session 01a0d3b5-6df4-79c3-9620-80a668ce73e1.
  • A static review of a detached snapshot, with no tests, GitHub or network access. The reviewer did not see the PR text or the author's conclusions.
  • Base 82e88f4ebbd89a4dad8b3b57ac412012601f0c45, head a283df41193f379f66aeb556f1efa89e450e036c. The snapshot was unchanged afterwards.

Covered: the diff, the sync and async metadata cursors, Glue client construction and error handling, the retry and pagination paths, the SQLAlchemy reflection callers, the new tests, and both docs changes.

Quoted: "I found no source-level defect in the traced fallback, page-resume, or exception paths."

Finding (P2), quoted:

The throttling test injects only Athena failures, then requires a real Glue response. An existing test environment with Athena metadata access but without glue:GetTable, glue:GetTables, or glue:GetDatabases will fail this assertion even though the cursor correctly returns to Athena after Glue fails. [...] The tests need a Glue-authorized environment or an explicit skip condition.

Author verification: the dependency is confirmed, and it is intended. The maintainer asked for Glue tests against the real API.

  • The CI role in cloudformation/github_actions_oidc.yaml already grants these actions on *, which covers the S3 Tables catalog.
  • docs/testing.md already required Glue permissions for local test identities, but without naming these actions.

Resolution: 2eb34cb names the three actions in docs/testing.md. There is no skip condition, because a silent skip would hide the real-Glue coverage.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repair and independent follow-up: CLEAN at 2eb34cb7ad05675463b6f165bd619337a997c3e5

Repair (docs only): docs/testing.md now says that the Glue metadata fallback tests call glue:GetTable, glue:GetTables and glue:GetDatabases directly, including against the S3 Tables catalog, and that the CI template grants them. just docs lint reports 0 errors.

Self-review of the repair:

  • There is no code change.
  • The claim was checked against cloudformation/github_actions_oidc.yaml:70-98, which grants the three actions on *, and against the test files that call Glue.

Independent follow-up: Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, session 01a0d3b8-fd2c-7051-9343-f8eed3350de2, on a detached snapshot at 2eb34cb, with the range diff from a283df4. Result: CLEAN (static review; tests not run).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CI failure on 2eb34cb, rebase, and test repair

CI failure: run 36009777585 failed test / run (3.11) in tests/pyathena/test_glue.py::TestGlueMetadataClient::test_reads_s3_tables_catalog (1 failed, 1751 passed). Glue GetTables raised EntityNotFoundException: The specified table does not exist (Service: S3Tables).

Cause (test design):

  • The test compared whole listings of the shared S3 Tables namespace.
  • The five Python-version jobs create and drop their own tables there concurrently, and a table dropped during the listing made the whole S3 Tables GetTables call fail.
  • Comparing separately timed whole-namespace listings is racy under that concurrency.
  • The library already handles this case: a listing's Glue EntityNotFoundException is not final and returns to the Athena request.

Rebase onto a2412cc (#811: rerun Athena service-side test failures). There were no conflicts. git range-diff 82e88f4..c1c0860 origin/master..2ca3829 shows all 21 commits identical. #811 edits another paragraph of docs/testing.md and changes no contract here.

Repair (c1c0860 → rebased 2ca3829, then e0e7799; test only):

  • The test creates a uniquely named table in the namespace.
  • It compares only that table, with get_table and with list_tables filtered by the table name as Expression against Athena's list_table_metadata(expression=...).
  • It asserts the Glue listing contains the table.
  • CREATE runs inside try, so finally drops the table even if CREATE fails after Athena made it.

Validation: uv run --env-file .env pytest -n 1 tests/pyathena/test_glue.py passed 16 of 16 twice on c1c0860, run after CI was idle. e0e7799 is covered by its CI run.

Independent follow-ups (Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, detached snapshots):

Session Head Result
01a0d41c-fbd5-7fd2-8bd8-c4f73b2ca335 2ca3829 FINDINGS: CREATE outside try; the filtered-list equality passes when both listings are empty
01a0d41e-897f-7e82-8d12-5d60028159bd e0e7799 CLEAN

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Repairs after be54210: retry_api_call(stop_on) and docstrings (maintainer requests), CLEAN at 9be45cb7599227ba1df3c4c42e3f80f7f56b6fb7

Rebase onto c43a01a (#813, benchmark uv workspace). #813 touches no file in this PR. git range-diff showed 23 of 23 commits identical.

7bbf128: extend the public helpers

  • retry_api_call and async_retry_api_call take a keyword-only stop_on predicate. The private _retry_api_call and _async_retry_api_call are gone.
  • Ten existing **request call sites annotate their dict as dict[str, Any], because mypy matched **dict[str, str] against stop_on. The values are unchanged.
  • Effect on outside callers:
    • Those passing **dict[str, str] get the same mypy error.
    • A wrapped function's own stop_on keyword is now consumed by retry_api_call. AWS parameters are CamelCase, so nothing in PyAthena is affected.
    • Both effects are stated in the PR body and the release note.

36ccb80 and 9be45cb: Google-style docstrings

  • Every function this PR touches, private ones included, has Args, Returns, Raises and Yields sections.
  • The API names in the docstrings were checked mechanically against the client calls: 15 of 15 match.
  • sphinx-build shows no warnings for the changed files.
  • 9be45cb scopes the s3_additional_kwargs description to open() and pipe_file().

Validation

  • The 62 targeted live tests passed on 0416eb4 (rebased to 7bbf128), run after CI was idle.
  • tests/pyathena/test_util.py passed 43 of 43.
  • Vacuity: ignoring stop_on fails test_retry_api_call_stops_on_predicate.
  • The docstring commits change no code. The offline test_util.py and test_glue.py cases pass on them.

Self-review of the repairs:

  • Behavior: the only change is the signature. Callers go through stop_on as before.
  • Claims: I had told the maintainer that only mypy is affected. The reviewer found the runtime shadowing case, and the PR text is corrected.

Independent follow-ups (Codex CLI 0.156.0, gpt-6-sol, codex exec --sandbox read-only, detached snapshots):

Session Head Result
01a0d5e6-7cc5-7873-a47e-0662c3e9f030 36ccb80 FINDINGS: stop_on shadows a wrapped function's own keyword (accepted and documented); the s3_additional_kwargs docstring was too broad (repaired)
01a0d5e9-50e4-7aa0-9fcb-98044b4bc3bb 9be45cb CLEAN

@laughingman7743
laughingman7743 force-pushed the feat/786-glue-metadata-fallback branch from 2eb34cb to 2ca3829 Compare September 24, 2026 15:50
@laughingman7743
laughingman7743 marked this pull request as ready for review September 24, 2026 16:41
@laughingman7743
laughingman7743 marked this pull request as draft September 24, 2026 23:56
laughingman7743 and others added 16 commits September 25, 2026 09:04
Athena rate-limits its metadata API per account, separately from Glue.
Table comments, table options and table, view and schema listings had
no answer while that limit held, so whole-table and whole-schema
reflection failed during a throttling episode.

In AwsDataCatalog and S3 Tables catalogs, a throttled GetTableMetadata,
ListTableMetadata or ListDatabases request now goes to Glue at once
(GetTable, GetTables, GetDatabases). The Glue table is flattened into
the metadata Athena reports; the rule was measured against
GetTableMetadata for Hive, partitioned, Iceberg, view, Glue-created and
S3 Tables tables. A failed Glue request, such as one without Glue
permission, falls back to the Athena request with the connection's
retries, so a caller without Glue access sees the previous behavior.

The Glue client uses the connection's session, region and config but not
Athena's endpoint or API version. The async dialect runs it in a worker
thread, as the adapted cursor runs its metadata calls.

Refs #786

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Athena throttles its metadata API per account, so any cursor can be
throttled, not only SQLAlchemy reflection. BaseCursor and AioCursor now
answer a throttled get_table_metadata, list_table_metadata or
list_databases from Glue in AwsDataCatalog and S3 Tables catalogs, and
the dialect reaches it through the cursor instead of calling Glue
itself. Column reflection and has_table() gain the same fallback; the
information_schema path remains for other catalogs and when Glue fails.

The fallback is on by default and can be turned off with the new
glue_metadata_fallback connection option (also a SQLAlchemy URL query
parameter). Glue's EntityNotFoundException surfaces as OperationalError
so callers see Athena's answer shape; other Glue failures return to the
Athena request with the retry policy. Throttling that Athena wraps in a
MetadataException counts as throttling.

The connection builds one Glue client on first use under a lock, from its
session, region and config but not Athena's endpoint_url or api_version.
The async cursor takes the client on the event loop and sends the Glue
request in a worker thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
pyathena/glue.py holds what is specific to Glue: which Athena catalogs it
can answer for and how to address them, the GetTable, GetTables and
GetDatabases requests with their pagination, and the flattening into the
metadata Athena reports. The cursors keep the decision of when to use it
and the retry policy around it, and the async cursor sends the wrapper's
request in a worker thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Glue request that cannot reach Glue (BotoCoreError, e.g. no route from
a network with only an Athena VPC endpoint) turns the fallback off for the
rest of the connection, so each later throttled request does not wait on
the Glue endpoint again.

The first Athena attempt leaves out MetadataException too, so Glue
throttling that Athena wraps in one reaches Glue at once even when the
retry policy lists MetadataException. A different failure that the policy
retries is then retried with the policy instead of being raised.

Only a table lookup takes Glue's EntityNotFoundException as the answer; a
listing returns to the Athena request, whose error for a missing catalog
or database callers already handle. The async cursor builds the Glue
client in the worker thread with the request, off the event loop. The
dialect's retry-policy copy uses the shared helper. The docs note that the
Glue request does not carry the workgroup.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Glue keeps an Iceberg table's dropped and renamed columns, marked
iceberg.field.current=false, which Athena's GetTableMetadata leaves out.
Measured after DROP COLUMN and CHANGE COLUMN; the Glue path now matches.

A first attempt that failed on a code its policy already retried is not
retried again with the full policy; only a MetadataException the first
attempt left out is. HasTableTest caught the doubled retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
retry_api_call gains a private variant that raises an exception a
predicate accepts at once. The Glue fallback's first attempt keeps the
cursor's retry policy and stops only at throttling, including Glue's own
inside a MetadataException, so other errors get their retries once and
the rerun and double retry-policy check go away.

Listings keep the pages already read, so the Athena request that follows
a failed Glue request resumes at the throttled page instead of starting
over. The schema default is resolved once per request, and the
throttling test helper is shared by the cursor and dialect tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The async test checks that the Glue client is built and the request sent
off the event loop; the client test passes Athena's api_version and builds
the client from several threads at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Timing cannot prove a lock is used, so the test asserts it is held while
the client is created, and that it is created once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The connection now holds one GlueMetadataClient instead of a public
glue_client property and an unreachable flag the cursor set. It builds
the Glue client on first use under its lock, without Athena's endpoint
and API version, and stops being used after a request that cannot reach
Glue at all: a connection error, a timeout, or no credentials or region.
A request that fails for another reason, such as invalid parameters, no
longer turns the fallback off.

The throttling predicate moves to pyathena/util.py. The Glue tests call
the real Glue API: a missing table and database, and a region that does
not exist for an unreachable endpoint; only Athena's throttling is
injected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
laughingman7743 and others added 7 commits September 25, 2026 09:04
A read timeout or dropped connection means Glue was reached, so it no
longer turns the fallback off for the connection; a connection error or
no Glue endpoint for the region and endpoint options does. No
credentials or region fail on the Athena request first, so they are not
listed. The failure log classifies the exception itself rather than the
shared flag, the requests no longer dispatch on an operation string,
listings convert page by page, and the public methods document their
arguments. Tests reach an unreachable Glue through a proxy port nothing
listens on instead of relying on DNS for a region that does not exist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One helper resolves the Glue request arguments for a catalog, the
listings build their results inside the reachability guard, and the
duplicated comments go. The tests build the unreachable Glue client in
tests/pyathena/util.py, compare every metadata field for S3 Tables, and
close the connections they open.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Other CI jobs create and drop tables in the shared S3 Tables namespace,
and a table dropped during GetTables made the whole listing fail with
EntityNotFoundException, so the test no longer compares the namespace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…listed

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
retry_api_call and async_retry_api_call take a keyword-only stop_on
predicate; the private _retry_api_call and _async_retry_api_call go.
Existing callers that pass **request built from a single-key literal now
annotate it as dict[str, Any], since mypy would otherwise match its
values against stop_on; the values are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@laughingman7743
laughingman7743 force-pushed the feat/786-glue-metadata-fallback branch from e0e7799 to 7bbf128 Compare September 25, 2026 00:05
laughingman7743 and others added 2 commits September 25, 2026 09:10
The new and changed functions, private ones included, document their
arguments, return values and raised exceptions: the Glue fallback flow,
the cursor metadata methods and their per-page requests, the Glue
client helpers, the retry helpers, and the existing request methods
whose request dicts gained type annotations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@laughingman7743
laughingman7743 marked this pull request as ready for review September 25, 2026 00:57
@laughingman7743
laughingman7743 merged commit 08425ff into master Sep 25, 2026
18 checks passed
@laughingman7743
laughingman7743 deleted the feat/786-glue-metadata-fallback branch September 25, 2026 00:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

get_table_comment and get_table_options have no throttling fallback

1 participant