Skip to content

feat(dbapi): add retry_aborts_internally option to Connection#16498

Open
waiho-gumloop wants to merge 1 commit intogoogleapis:mainfrom
waiho-gumloop:feat/dbapi-retry-aborts-internally
Open

feat(dbapi): add retry_aborts_internally option to Connection#16498
waiho-gumloop wants to merge 1 commit intogoogleapis:mainfrom
waiho-gumloop:feat/dbapi-retry-aborts-internally

Conversation

@waiho-gumloop
Copy link
Copy Markdown
Contributor

Summary

Add a retry_aborts_internally flag to the DBAPI Connection class and the connect() function. When set to False, aborted transactions raise RetryAborted directly from commit() instead of entering the internal statement-replay retry loop.

Changes

  • Connection.__init__: Accept retry_aborts_internally parameter (default True)
  • Connection.retry_aborts_internally: Property getter/setter with guard against mid-transaction changes
  • Connection.commit(): Check _retry_aborts_internally before entering the replay loop; when disabled, wrap Aborted in RetryAborted for PEP 249 compliance
  • connect(): Pass-through retry_aborts_internally to Connection
  • Tests: 8 new unit tests covering default behavior, constructor override, setter, setter-during-transaction guard, commit with retry enabled, and commit with retry disabled

Rationale

Why the internal retry was added

The DBAPI's statement-replay retry was introduced to support Django and other PEP 249 ORMs. These frameworks build transactions incrementally through individual cursor.execute() calls -- the DBAPI layer sees a sequence of statements but has no callable representing the full transaction. When Spanner aborts a transaction, the only option is to replay all recorded statements and verify checksums to ensure read consistency.

Why applications may not want the internal retry

Applications that implement their own transaction retry logic -- wrapping the entire transaction in a callable and re-invoking it with a fresh session on abort -- do not need transparent statement replay. When both layers retry simultaneously, the result is nested retry loops that cause:

  1. Contention amplification: The internal replay acquires locks on the same rows that caused the original abort, triggering cascading aborts across threads.
  2. Wasted work: The internal retry replays up to 50 times with its own backoff, accumulating 13-19 seconds before raising. The outer retry then starts fresh.
  3. Checksum mismatches: For read-modify-write patterns, replayed reads return different data, causing checksums to always fail.

In production with 10 concurrent writers, disabling the internal retry reduced abort-to-recovery time from ~18s to ~0.05s and improved success rates from ~55% to 100%.

Precedent

This aligns with existing functionality in other Spanner clients:

Library Mechanism Default
JDBC RETRY_ABORTS_INTERNALLY connection property true
Go NewReadWriteStmtBasedTransaction vs ReadWriteTransaction Separate API
Python DBAPI (this PR) retry_aborts_internally constructor/connect parameter True

Usage

from google.cloud.spanner_dbapi import connect

# Default behavior (unchanged)
conn = connect("instance", "database", project="project", credentials=creds)

# Disable internal retry for application-managed retries
conn = connect("instance", "database", project="project", credentials=creds,
               retry_aborts_internally=False)

Tests

  • test_retry_aborts_internally_defaults_true
  • test_retry_aborts_internally_set_false
  • test_retry_aborts_internally_setter
  • test_retry_aborts_internally_setter_while_transaction_active
  • test_commit_retries_internally_when_enabled
  • test_commit_raises_retry_aborted_when_internal_retry_disabled
  • test_connect_retry_aborts_internally_default
  • test_connect_retry_aborts_internally_false

Fixes #16491

Add a retry_aborts_internally flag to the DBAPI Connection class and
the connect() function. When set to False, aborted transactions raise
RetryAborted directly from commit() instead of entering the internal
statement-replay retry loop.

This aligns with RETRY_ABORTS_INTERNALLY in the Spanner JDBC driver
and avoids nested retry loops when the application manages its own
transaction retry logic.

Fixes googleapis#16491
@waiho-gumloop waiho-gumloop requested review from a team as code owners April 1, 2026 00:49
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the retry_aborts_internally configuration for Spanner DBAPI connections, enabling users to opt-out of automatic internal transaction retries. This is particularly useful for applications that manage their own retry logic to avoid nested loops. The implementation includes a new connection parameter, a property with a setter that prevents changes during active transactions, and updated logic in the commit method. A review comment correctly identifies that the RetryAborted exception is used without being imported in connection.py, which would result in a NameError at runtime.

self._transaction_helper.retry_transaction()
self.commit()
else:
raise RetryAborted(str(exc)) from exc
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

high

The RetryAborted exception is used here but it does not appear to be imported in this file. This will cause a NameError at runtime when a transaction is aborted and internal retries are disabled. Please ensure RetryAborted is imported from google.cloud.spanner_dbapi.exceptions at the top of the file.

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.

feat(dbapi): add retry_aborts_internally option to disable internal statement-replay retry

1 participant