Skip to content

feat(spanner): add native AsyncIO Spanner adapter, extension stores, and sync/async parity - #831

Merged
cofin merged 42 commits into
mainfrom
feat/spanner-async-research
Oct 7, 2026
Merged

cofin merged 42 commits into
mainfrom
feat/spanner-async-research

Conversation

@cofin

@cofin cofin commented Oct 5, 2026

Copy link
Copy Markdown
Member

Summary

Adds full native AsyncIO support for Google Cloud Spanner (google.cloud.spanner_v1._async introduced in google-cloud-spanner>=3.64.0) alongside sync/async parity and performance optimizations across the Spanner adapter, data dictionary, and framework extension stores.

Key Changes

  1. Native AsyncIO Spanner Adapter Stack (sqlspec.adapters.spanner)

    • SpannerAsyncConfig(AsyncDatabaseConfig): Manages AsyncClient, await config.get_database(), AsyncBurstyPool (default) / AsyncFixedSizePool / AsyncPingingPool / AsyncTransactionPingingPool session pools, SpannerAsyncConnectionContext, _SpannerAsyncSessionConnectionHandler, provide_session(), provide_read_session(), provide_write_session(), and await config.run_in_transaction(...).
    • SpannerAsyncDriver(AsyncDriverAdapterBase): Implements SpannerAsyncExceptionHandler, _SpannerAsyncSelectStreamSource (AsyncRowStream), dispatch_execute, dispatch_execute_many, dispatch_execute_script, select_stream, select_to_arrow, select_to_storage, load_from_arrow, load_from_storage, _batch_write_mutations, begin, commit, rollback, savepoints, and per-call execution options (request_options, query_options, directed_read_options, retry, timeout, last_statement).
    • SpannerAsyncDataDictionary(AsyncDataDictionaryBase): Shares GoogleSQL and Spangres PostgreSQL dialect metadata routing with SpannerSyncDataDictionary (SpannerDataDictionary).
    • Symmetric *Sync* / *Async* Exports: Exposes SpannerSyncConnection, SpannerSyncConnectionContext, SpannerSyncCursor, SpannerSyncDataDictionary, SpannerSyncDriver, SpannerSyncExceptionHandler, and SpannerSyncSessionContext alongside their SpannerAsync* counterparts while retaining unprefixed aliases for backward compatibility.
  2. Native AsyncIO Extension Stores

    • Google ADK (sqlspec.adapters.spanner.adk): SpannerAsyncADKStore(BaseAsyncADKStore[SpannerAsyncConfig]) and SpannerAsyncADKMemoryStore(BaseAsyncADKMemoryStore[SpannerAsyncConfig]) sharing DDL and query builders with the sync stores via _SpannerADKStoreCommonMixin and _SpannerADKMemoryStoreCommonMixin.
    • Litestar Sessions (sqlspec.adapters.spanner.litestar): SpannerAsyncStore(BaseSQLSpecStore[SpannerAsyncConfig]) executing native async snapshot reads and await database.run_in_transaction(...) writes without worker-thread offloading.
    • Events (sqlspec.adapters.spanner.events): SpannerAsyncEventQueueStore(BaseEventQueueStore[SpannerAsyncConfig]) sharing _SpannerEventQueueStoreMixin with SpannerSyncEventQueueStore.
  3. Sync + Async Parity & Performance Optimizations

    • run_in_transaction Abort Retry Unwrapping: Both SpannerSyncConfig.run_in_transaction / SpannerSyncDriver.run_in_transaction and SpannerAsyncConfig.run_in_transaction / SpannerAsyncDriver.run_in_transaction unwrap DeadlockError.__cause__ when caused by google.api_core.exceptions.Aborted so Spanner's native transaction runner backs off and retries on 409 Aborted, mapping terminal Aborted exceptions back to DeadlockError.
    • Pool Keepalive & Bidirectional Pool Type Mapping: SpannerSyncConfig._create_pool and SpannerAsyncConfig._create_pool default ping_interval=1800 on PingingPool / TransactionPingingPool and automatically map sync $\leftrightarrow$ async pool classes.
    • FLOAT32 Vectors & INTERVAL Parameter Inference: Supports FLOAT32, ARRAY<FLOAT32> / VECTOR (TypedParameter), and INTERVAL (datetime.timedelta) parameter type inference and coercion in sqlspec/adapters/spanner/type_converter.py.
    • Zero-Copy JsonObject Unwrapping: _convert_json_row_value unwraps JsonObject cells directly when json_deserializer is from_json, avoiding redundant serialize() string round-trips.
  4. Tests & Documentation

    • Added unit tests across tests/unit/adapters/test_spanner/ (test_async_driver.py, test_config.py, test_adk_store.py, test_litestar_store.py, test_events_store.py, test_data_dictionary_routing.py, test_spanner_type_inference.py, test_core.py, test_init.py) and cross-adapter test registries.
    • Added Spanner emulator async fixtures (spanner_async_config, spanner_async_session, spanner_async_write_session, spanner_async_read_session) and integration suite (tests/integration/adapters/spanner/spanner/test_async_driver.py).
    • Updated Spanner reference and extension documentation (docs/reference/adapters/spanner.rst, docs/reference/adapters/index.rst, docs/reference/driver.rst, docs/usage/frameworks/litestar/session_stores.rst, docs/extensions/adk/backends.rst, docs/extensions/adk/adapters.rst).

Test Plan

  • uv run pytest tests/unit/adapters/test_spanner/ tests/unit/adapters/test_extension_config.py
  • uv run pytest tests/unit (13,275 passed)
  • uv run mypy sqlspec/adapters/spanner tests/unit/adapters/test_spanner
  • uv run pyright sqlspec/adapters/spanner tests/unit/adapters/test_spanner
  • uv run ruff check sqlspec/adapters/spanner tests/unit/adapters/test_spanner
  • uv run python tools/scripts/mypyc_inventory.py --check

cofin added 18 commits October 4, 2026 16:54
@codecov-commenter

codecov-commenter commented Oct 5, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.40840% with 207 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.71%. Comparing base (c25a01d) to head (204fb2c).

Files with missing lines Patch % Lines
sqlspec/adapters/spanner/data_dictionary.py 37.38% 64 Missing and 3 partials ⚠️
sqlspec/adapters/spanner/driver.py 86.06% 38 Missing and 18 partials ⚠️
sqlspec/adapters/spanner/core.py 88.71% 15 Missing and 14 partials ⚠️
sqlspec/adapters/spanner/config.py 90.74% 8 Missing and 17 partials ⚠️
sqlspec/adapters/spanner/litestar/store.py 95.45% 6 Missing and 4 partials ⚠️
sqlspec/dialects/spanner/_generators.py 72.41% 6 Missing and 2 partials ⚠️
sqlspec/adapters/spanner/type_converter.py 90.00% 3 Missing and 2 partials ⚠️
sqlspec/core/_operators.py 57.14% 1 Missing and 2 partials ⚠️
sqlspec/dialects/spanner/_parsers.py 96.15% 1 Missing and 1 partial ⚠️
sqlspec/adapters/spanner/_typing.py 97.50% 0 Missing and 1 partial ⚠️
... and 1 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #831      +/-   ##
==========================================
- Coverage   82.58%   81.71%   -0.87%     
==========================================
  Files         521      521              
  Lines       78458    79815    +1357     
  Branches    11342    11531     +189     
==========================================
+ Hits        64792    65224     +432     
- Misses      10734    10866     +132     
- Partials     2932     3725     +793     
Flag Coverage Δ
integration 62.65% <68.15%> (+0.26%) ⬆️
py3.10 80.05% <84.83%> (+0.47%) ⬆️
py3.11 80.04% <84.83%> (+0.45%) ⬆️
py3.12 80.05% <84.83%> (+0.46%) ⬆️
py3.13 80.04% <84.83%> (+0.45%) ⬆️
py3.14 81.06% <86.16%> (+0.44%) ⬆️
unit 72.57% <82.79%> (+0.24%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
sqlspec/adapters/arrow_odbc/driver.py 84.37% <100.00%> (-4.17%) ⬇️
sqlspec/adapters/spanner/__init__.py 100.00% <100.00%> (ø)
sqlspec/adapters/spanner/adk/__init__.py 100.00% <100.00%> (ø)
sqlspec/adapters/spanner/adk/store.py 73.68% <ø> (+6.91%) ⬆️
sqlspec/adapters/spanner/events/__init__.py 100.00% <100.00%> (ø)
sqlspec/adapters/spanner/events/store.py 98.71% <100.00%> (+10.58%) ⬆️
sqlspec/adapters/spanner/litestar/__init__.py 100.00% <100.00%> (ø)
sqlspec/builder/_base.py 80.92% <100.00%> (-6.80%) ⬇️
sqlspec/builder/_parsing_utils.py 78.63% <100.00%> (ø)
sqlspec/core/filters.py 93.08% <100.00%> (ø)
... and 21 more

... and 65 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

cofin and others added 11 commits October 5, 2026 14:27
mypyc 2.4.0 fails with an internal AssertionError when compiling a for
loop over reversed(...) whose items involve Any. Iterate a reversed
slice instead so compiled wheels build on both mypy 2.3 and 2.4.
JSONBContainsTopKey first appears in sqlglot 30.18, so importing
sqlspec.core failed on 30.13 through 30.17. Register the ?? rendering
only when SQLGlot defines the expression.
Async ADK base stores now expose the reset-drop hooks as coroutines,
matching the other DDL hooks, and the create migration awaits them on
rollback. Adapters that need a database lookup to decide which tables
to drop can now do so from an async store.
The async Spanner ADK session and memory stores now drop only tables
that exist, as the sync stores already did, so rolling back the ADK
migration no longer fails on missing tables.

The stores now call the async SDK directly, declare slots, share
option parsing, and use sync/async helper pairs with matching names.
Group the stores under an Extensions section ahead of Extension Settings, promote native execution controls and parameter types to top-level sections, document the SpannerSync* names, and record the async adapter, renames, and fixes in the changelog.
Run duplicated driver checks and transaction completion tests against both adapters through a mode-parametrized config, and make the async config fixture session-scoped like the other async adapters.
cofin added 13 commits October 6, 2026 17:09
Run the shared ADK and Litestar store contracts against both Spanner stores, add an ADK memory round-trip, and exercise the event queue lifecycle and metadata on both adapters.
… one loop

Try the full optimizer rules, then the same rules without
pushdown_projections when SQLGlot raises OptimizeError, instead of a
nested try that repeats the optimize call.
TypedParameter.original_type stays a Python type. Spanner reads a
declared type such as FLOAT32, ARRAY<FLOAT32>, VECTOR, or INTERVAL from
semantic_name and binds FLOAT32 and INTERVAL through param_types
directly.
…vers

- Name sync classes SpannerSync* (exception handler, data dictionary,
  connection and session contexts, connection alias) and drop the
  unprefixed and bare Async* aliases; exports follow psycopg.
- Share one commit/rollback decision between both connection contexts
  and drivers; an explicit rollback is no longer followed by a commit,
  and rollback discards mutations buffered before the transaction began.
- Call the async SDK directly instead of probing for awaitables, and
  move run_in_transaction, script query detection, DDL helpers, and the
  stream sources (now SpannerSyncStreamSource/SpannerAsyncStreamSource)
  into core.py.
- Reject a session pool class from the other sync/async variant, default
  both configs to FixedSizePool, report no native Parquet support for
  either, and map load_from_arrow errors in the sync driver too.
- Drop the undeclared database_provider driver feature.
…pannerSyncTransactionType

The ADK session and memory stores list tables and apply DDL through the
core helpers used by the Litestar and events stores. The sync
TransactionType alias is SpannerSyncTransactionType, and the changelog
covers the sync rollback fix, semantic_name type declarations, and pool
class validation.
TransactionType is a Spanner SDK re-export, so it keeps the Spanner
plus SDK class name form used by the other SDK aliases.
Spanner requires an explicit length on STRING and BYTES columns and
parentheses around column defaults. Column definitions now render
unsized text and binary types as STRING(MAX) and BYTES(MAX), and
defaults as DEFAULT (expr); query casts keep the unsized forms.
Script splitting treated a semicolon inside a backtick-quoted identifier
as a statement terminator, which broke MySQL, BigQuery and Spanner
scripts that quote identifiers with backticks. Spanner and Spangres
scripts now use the GoogleSQL and PostgreSQL splitter rules instead of
the generic fallback.
Spanner rejects DDL sent through a transaction, so migrations and any
schema change failed on both adapters. DDL statements now run through
the database update_ddl API, consecutive DDL in a script is applied as
one schema change, and a begun write transaction is committed before a
schema change.

Write sessions also keep working after commit() or rollback(): the next
statement runs in a new transaction on the same session, and session
release completes the transaction the driver holds. Drivers inside
run_in_transaction leave commit and retry to the SDK.

The shared migration contracts now run against the Spanner emulator in
both modes.
Read sessions rejected DML but let DDL through to the schema API, so a
read-only session could change the schema. DDL from a Snapshot session
now raises SQLConversionError, as DML does.
SQLGlot could not parse Spanner's THEN RETURN clause, so the statement
was treated as plain DML and its returned rows were discarded. The
Spanner dialect now parses THEN RETURN [WITH ACTION [AS alias]] into a
Returning clause and renders it back, so these statements return rows
on both adapters, including repeated cached executions.
The pinned upload-artifact and download-artifact commits are v7.0.1 and
v8.0.1, but their comments named the floating v7 and v8 tags, which have
since moved. zizmor's online audit flags the mismatch.
@cofin
cofin merged commit 71ba3ac into main Oct 7, 2026
40 of 42 checks passed
@cofin
cofin deleted the feat/spanner-async-research branch October 7, 2026 21:46
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.

2 participants