Skip to content

feat: add asyncio dialect (databend+async) - #85

Open
rad-pat wants to merge 2 commits into
databendlabs:mainfrom
PlaidCloud:upstream-pr/async-dialect
Open

rad-pat wants to merge 2 commits into
databendlabs:mainfrom
PlaidCloud:upstream-pr/async-dialect

Conversation

@rad-pat

@rad-pat rad-pat commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds an asyncio dialect, so Databend works with create_async_engine, AsyncConnection.stream() and AsyncSession:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine("databend+async://user:password@host:8000/db?sslmode=disable")

create_async_engine given a plain databend:// URL selects the async dialect too. Install with pip install "databend-sqlalchemy[asyncio]", which brings in greenlet. SQLAlchemy 2.1 no longer installs greenlet by default.

This PR also fixes CI, which has been failing since mid-October for reasons unrelated to the open PRs (see below), and moves packaging to pyproject.toml.

How it works

  • databend_sqlalchemy/async_connector.py: the driver's AsyncDatabendClient has no async cursor, so this adds a PEP 249-style async connection and cursor.
    • It renders parameters exactly as the blocking cursor does, through an OperationRenderer mixin pulled out of connector.py. Sync behaviour is unchanged: across about 2,000 parameter and statement shapes, the SQL handed to the driver is byte-identical to before.
    • All cursors of a connection share one driver connection, so temporary tables and SET persist across a connection's statements. The blocking connector opens a session per cursor.
    • Batch fetches (fetchmany/fetchall) drain the driver's row iterator in a worker thread, one hop per batch. Awaiting the iterator once per row costs a round trip through the driver's runtime each time, which made 200k rows take about 24 s; it now takes about 0.4 s.
  • databend_sqlalchemy/async_dialect.py: builds on SQLAlchemy's asyncio adapter (sqlalchemy.connectors.asyncio).
    • Results are buffered by default and stream with stream() or stream_results.
    • The cursor also buffers the rows executemany leaves behind, because post_exec reads the inserted row count back from them.

Tests

  • The full SQLAlchemy compliance suite runs against the async dialect with pytest --db async, added as a second CI step.
  • New tests/test_async.py covers the asyncio API itself:
    • buffered results that outlive the connection;
    • streaming, checking that rows are still pending in the driver after the first partition;
    • single and executemany rowcounts;
    • session state shared across statements;
    • errors raised as DBAPIError;
    • async_creator;
    • an ORM AsyncSession round trip.
  • ServerSideCursorsTest overrides now assert real behaviour. test_ss_cursor_status used to call a bare super() and so never ran. It now checks buffered and streamed cursors under the async dialect.
Python SQLAlchemy databend-driver sync async
3.11 (as CI) 2.1.6 0.34.2 744 passed 756 passed
3.13 (Windows) 2.0.48 0.27.4 711 passed 723 passed
3.10 1.4.54 0.34.2 35 passed (compliance suite not collected) n/a

All runs had 0 failures. Before rebasing onto current main, the same changes also passed on:

  • Python 3.10 with SQLAlchemy 2.0.55;
  • Python 3.14 and 3.15rc1 with SQLAlchemy 2.1.6;
  • the minimum versions: Python 3.10, SQLAlchemy 2.0.42 and databend-driver 0.26.2.

CI fixes

The open PRs fail CI because the job dies before any tests run:

  • MinIO: the databend:nightly image's MinIO download now returns HTTP 410. MinIO never starts, so Databend fails with StorageUnavailable and the health check gets a connection reset. No test needs object storage (stage COPY INTO works on the default fs storage), so MINIO_ENABLED and port 9000 are removed.
  • SQLAlchemy 2.1 (installed by pipenv --skip-lock) removed DeprecatedCompoundSelectTest, so tests/test_sqlalchemy.py failed to import. That import and override are now guarded to < 2.1.0.
  • Sequence tests race with -n4: Databend sequences belong to the tenant, not a database, so parallel workers raced on SequenceTest's tab_id_seq (SequenceAlreadyExists / UnknownSequence).
    • The three sequence test classes now share one xdist_group, and pytest runs with --dist loadgroup, so they run on a single worker while everything else stays parallel.
    • conftest.py runs SQLAlchemy's collection hook first; otherwise xdist tags the items before SQLAlchemy replaces them with the per-backend classes.
  • SQLAlchemy 1.4: a new CI step runs the tests on SQLAlchemy 1.4 so Superset compatibility doesn't regress. The compliance suite needs SQLAlchemy 2.0.42's test requirements (nvarchar_types) and isn't collected below that, nor are the async tests below 2.0.28. New tests/test_basic_usage.py therefore covers what Superset relies on, on every SQLAlchemy release: reflection, views, executemany, LIMIT/OFFSET, text() with %, and type round trips.

Packaging

  • pyproject.toml: metadata moves there from setup.cfg (PEP 621, with a PEP 639 licence expression). setup.cfg keeps only the flake8 and SQLAlchemy test-plugin sections, which have no pyproject.toml support.
  • asyncio extra added.
  • Python: requires-python >= 3.10, which the package already needed (types.NoneType, runtime X | Y unions). Classifiers cover 3.10 to 3.15.
  • SQLAlchemy: still >= 1.4 for the sync dialect, because Superset (up to its current 6.1 release) requires SQLAlchemy < 2. The asyncio extra requires >= 2.0.28, and the async dialect raises an ImportError saying so on anything older. 2.0.28 is the first release where ExecutionContext.rowcount returns the _rowcount the dialect sets in post_exec. Before it, rowcount is -1 for INSERT/UPDATE/DELETE/COPY INTO.
  • README is published as text/x-rst (it was declared as markdown), and one short heading underline is fixed so it renders. twine check --strict passes.
  • superset extra now points at apache-superset.

Known limitations

  • Pre-existing: the executemany multi-row INSERT path doesn't undo the escaper's % doubling, so '50%' is stored as '50%%'. This affects sync and async alike; single-row inserts are fine.
  • Abandoned streams on older drivers: with databend-driver < 0.34, a stream abandoned before it's exhausted leaves its query parked on the server until it times out. This happens with the sync dialect too; 0.34 kills the query when the iterator is dropped.
  • Cancellation: a cancelled async statement isn't killed on the server.

🤖 Generated with Claude Code

Add an asyncio dialect on top of databend-driver's AsyncDatabendClient, so
create_async_engine("databend+async://...") works, and a plain databend://
URL passed to create_async_engine selects it too.

- async_connector: a PEP 249-style async connection and cursor sharing the
  blocking cursor's parameter rendering (pulled out into OperationRenderer).
  All cursors of a connection share one Databend session, so temp tables
  and SET persist across statements. Batch fetches drain the driver's row
  iterator in a worker thread, since awaiting it once per row is about a
  hundred times slower.
- async_dialect: built on SQLAlchemy's asyncio adapter, buffering results by
  default and streaming with stream() or stream_results.
- The full SQLAlchemy compliance suite runs against the async dialect with
  --db async, added as a CI step, plus tests/test_async.py for the asyncio
  API itself. ServerSideCursorsTest overrides now assert real behaviour.

CI and packaging:
- Drop MINIO_ENABLED from CI: the nightly image's MinIO download now returns
  HTTP 410, so Databend never starts; no test needs object storage.
- Skip DeprecatedCompoundSelectTest on SQLAlchemy 2.1, which removed it.
- Keep four pytest workers but put the sequence tests in one xdist group
  (--dist loadgroup): Databend sequences belong to the tenant, not a
  database, so workers raced on SequenceTest's tab_id_seq. conftest runs
  SQLAlchemy's collection hook first so xdist sees the expanded classes.
- Move packaging metadata from setup.cfg to pyproject.toml, add an asyncio
  extra, declare Python 3.10 to 3.15 and require SQLAlchemy >= 2.0.28, the
  first release that reports the row counts the dialect reads from
  Databend. SQLAlchemy 1.4 compatibility branches are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EPoszSnPRjj34ySEsF4p68
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-23T13:09:43.367803Z 37fbfed PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 37fbfed601

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread pyproject.toml Outdated
Superset, up to its current 6.1 release, still requires SQLAlchemy < 2, so
requiring SQLAlchemy 2.0.28 would stop Superset users installing this
release.

- Depend on sqlalchemy >= 1.4 again and restore the DOUBLE fallback. Only
  the asyncio extra needs SQLAlchemy 2.0.28, and the async dialect raises
  an ImportError naming that requirement on any older release.
- Add tests/test_basic_usage.py, covering reflection, views, executemany,
  LIMIT/OFFSET, text() with % and type round trips on every supported
  SQLAlchemy release. The compliance suite needs SQLAlchemy 2.0.42's test
  requirements, so it isn't collected below that.
- Below 2.0.28, don't collect the async tests, and skip only the COPY INTO
  row count assertions, as older releases report row counts as -1.
- Run the tests on SQLAlchemy 1.4 in CI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rad-pat

rad-pat commented Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

@hantmac, please take a look if you get chance 👍

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.

1 participant