Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,14 +68,14 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30" # NOSONAR
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy" # NOSONAR
# NOSONAR(githubactions:S8541, S8544): both sibling projects are installed
# from a git reference on purpose. The release branches are the contract
# between these repositories, the test suite is still on a development
# branch until it merges, and neither repository publishes a lock file to
# pin against -- a hash-pinned install of a moving branch would be a pin
# that goes stale on the next commit instead of a safety property.
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17" # NOSONAR
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy" # NOSONAR

- name: Install dependencies
run: |
Expand Down
1 change: 1 addition & 0 deletions changelog.d/14.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Classified Firebird driver errors into the semantic error taxonomy (data, record, policy, concurrency, infrastructure) and aligned `execute` / `execute_many` with the execute-phase error contract: framework errors pass through, auto-commit failures raise `TransactionError`, and only transient connection failures are retried. Added contract tests covering the mapping and phases.
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,9 @@ async def connect(self) -> None:
self.log(logging.INFO, "Connected to Firebird (async)")
track_firebird_backend(self)
except Exception as e:
raise exc.ConnectionError(f"Failed to connect to Firebird: {e}") from e
if isinstance(e, exc.DatabaseError):
raise
await self._handle_error(e)

async def disconnect(self) -> None:
"""Close the connection to the Firebird database."""
Expand Down Expand Up @@ -306,15 +308,28 @@ async def _handle_error(self, error: Exception) -> None:
# A failed statement leaves the cursor with an attached firebird
# Statement; closing it here avoids a GC-time abort in a worker thread
# once the connection is gone (firebird-driver 2.0.x behaviour).
try:
if self._cursor is not None:
loop = asyncio.get_event_loop()
await loop.run_in_executor(self._executor, self._cursor.close)
except Exception:
pass
self._cursor = None
await self._close_cursor_quietly()
FirebirdBackendMixin._handle_error(self, error)

async def _close_cursor_quietly(self) -> None:
"""Close the shared cursor without letting a close failure mask a cause.

The original exception is already propagating when this runs, so a
close failure is logged and discarded rather than raised.
"""
cursor = self._cursor
self._cursor = None
if cursor is None:
return
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(self._executor, cursor.close)
except Exception as error:
self.log(
logging.DEBUG,
f"Error closing cursor after failed execution, ignored: {error}",
)

# ------------------------------------------------------------------
# Execution hooks — wrap sync fdb operations in run_in_executor
# ------------------------------------------------------------------
Expand Down Expand Up @@ -390,12 +405,25 @@ def _char_columns_from_cursor(self, cursor) -> set:
return FirebirdBackend._char_columns_from_cursor(cursor)

async def _handle_auto_commit(self) -> None:
"""Handle auto commit in thread pool."""
"""Commit the connection's active transaction in the executor.

Mirrors the synchronous contract: a failed commit raises
:class:`TransactionError` from the driver exception, so the caller
never receives a result whose data was not committed. A connection
without an active transaction has nothing to commit.
"""
connection = self._connection
if connection is None:
return
main_transaction = getattr(connection, "main_transaction", None)
is_active = getattr(main_transaction, "is_active", None)
if callable(is_active) and not is_active():
return
loop = asyncio.get_event_loop()
try:
await loop.run_in_executor(self._executor, self._connection.commit)
except Exception:
pass
await loop.run_in_executor(self._executor, connection.commit)
except Exception as error:
raise exc.TransactionError("Auto-commit failed") from error

async def executescript(self, sql_script: str) -> None:
"""Execute a multi-statement SQL script asynchronously.
Expand All @@ -418,19 +446,27 @@ async def executescript(self, sql_script: str) -> None:
self.log(logging.INFO, f"Async SQL script executed successfully, duration={duration:.3f}s")
await self._handle_auto_commit_if_needed()
try:
cursor.close()
except Exception:
pass
await loop.run_in_executor(self._executor, cursor.close)
except Exception as error:
# Closing after a successful script is cleanup: a close failure
# must not turn success into an error.
self.log(logging.DEBUG, f"Error closing script cursor, ignored: {error}")
except Exception as e:
if cursor is not None:
try:
cursor.close()
except Exception:
pass
await loop.run_in_executor(self._executor, cursor.close)
except Exception as error:
# The script failure below is the cause the caller must see.
self.log(logging.DEBUG, f"Error closing failed script cursor, ignored: {error}")
if isinstance(e, exc.DatabaseError):
raise
# The exception is re-raised by _handle_error; a second ERROR
# record would duplicate the application's own traceback.
self.log(logging.DEBUG, f"Error executing async SQL script, will be re-raised: {e}")
await self._handle_error(e)
# Defensive contract: a handler that returns must not swallow the
# script failure.
raise exc.DatabaseError(str(e)) from e

# ------------------------------------------------------------------
# SQL helpers
Expand Down Expand Up @@ -491,9 +527,16 @@ async def execute(
try:
return await super().execute(sql, params, options=options)
except Exception as e:
# Mirror the synchronous backend: the errored cursor is closed
# before the failure propagates, and its close failure must not
# replace the original exception.
await self._close_cursor_quietly()
if isinstance(e, exc.DatabaseError):
raise
await self._handle_error(e)
# Defensive contract: a handler that returns must not turn a
# failed execute into a None result.
raise exc.DatabaseError(str(e)) from e

# ------------------------------------------------------------------
# High-level operations
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,32 @@ def execute(
if isinstance(e, exc.DatabaseError):
raise
self._handle_error(e)
# _handle_error() is required to raise. Keep a defensive fallback so
# a handler that returns cannot turn a failed execute into success.
raise exc.DatabaseError(str(e)) from e

def _handle_auto_commit(self) -> None:
"""Commit the connection's active transaction.

The result is built just before this call, so a failed commit must not
leave the caller with a "successful" result: the failure surfaces as
:class:`TransactionError` carrying the driver exception.

``Connection.commit()`` goes through firebird-driver's main transaction
manager, which has nothing to commit before the first statement starts
a transaction. That state is not a commit failure, so it is skipped.
"""
connection = self._connection
if connection is None:
return
main_transaction = getattr(connection, "main_transaction", None)
is_active = getattr(main_transaction, "is_active", None)
if callable(is_active) and not is_active():
return
try:
self._connection.commit()
except Exception:
pass
connection.commit()
except Exception as error:
raise exc.TransactionError("Auto-commit failed") from error

def execute_many(
self,
Expand All @@ -225,20 +245,22 @@ def execute_many(

The caller's ``options`` - including its log context - is forwarded so
the core batch logging stays the single owner of SQL/parameter records.
The core pipeline owns auto-commit for the batch; committing here again
would commit the same transaction twice.
"""
is_dml = sql.strip().upper().startswith(('INSERT', 'UPDATE', 'DELETE', 'MERGE'))
try:
result = super().execute_many(sql, params_list, options=options)
if result and result.affected_rows < 0 and len(params_list) > 0:
result.affected_rows = len(params_list)
if is_dml:
self._handle_auto_commit_if_needed()
return result
except Exception as e:
# Re-raise already-wrapped rhosocial errors unchanged; see execute().
if isinstance(e, exc.DatabaseError):
raise
self._handle_error(e)
# Defensive contract: a handler that returns must not swallow the
# batch failure.
raise exc.DatabaseError(str(e)) from e

def executescript(self, sql: str) -> QueryResult:
"""Execute a multi-statement SQL script.
Expand All @@ -262,6 +284,7 @@ def executescript(self, sql: str) -> QueryResult:
raise
self.log(logging.DEBUG, f"Error executing SQL script, exception will be re-raised: {e}")
self._handle_error(e)
raise exc.DatabaseError(str(e)) from e

def get_server_version(self) -> Tuple[int, int, int]:
if not self._is_connected:
Expand Down
Loading
Loading