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
31 changes: 5 additions & 26 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,32 +20,11 @@ permissions:
env:
# rhosocial-activerecord and rhosocial-activerecord-testsuite dev releases are
# not published to PyPI; install them from git.
# The schema-object refactor this branch depends on was developed on the core
# library's expression-schema-object-refactor branch and merged to
# release/v1.0.0.dev30 (PR #125), and the introspection ownership rule this
# branch completes was merged there too (PR #128). CORE_PKG therefore pins the
# release branch, and TESTSUITE_PKG pins the matching testsuite release branch,
# so the two move together. Two of the
# four names a previous revision of this comment listed -- QualifiedNameMixin
# and CatalogSupport -- are ones that work *deleted*, along with the
# single-`supports_schema_qualification` object model this backend used to
# assume. What the dialect actually imports from core is:
#
# * the per-kind object mixins and NamespaceMixin, which supply
# format_<kind>_object for the fourteen kinds that carry a namespace and
# the levels behind them (a Database is the one kind that does not -- it
# is never qualified, so format_database_object is name only);
# * RelationSourceMixin, which renders the FROM side of a named object --
# without it, rendering any aliased row source raises AttributeError;
# * the object protocols (ViewObjectSupport, IndexObjectSupport), which
# derive from NamespaceSupport and so must follow this backend's own
# namespace mixin in the base list (C3).
#
# The previous pin, the fix/schema-name-propagation-gaps branch, predates all
# of that layer, so the imports this backend now makes do not resolve against
# it.
CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@release/v1.0.0.dev30"
TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"
# This branch pins the core and testsuite feature branches so the error-taxonomy
# and execute-contract changes under review are installed together. Restore the
# release-branch pins once those branches are merged.
CORE_PKG: "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/error-taxonomy"
TESTSUITE_PKG: "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@feature/error-taxonomy"

jobs:
test:
Expand Down
1 change: 1 addition & 0 deletions changelog.d/10.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Classified BigQuery 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
@@ -1,12 +1,12 @@
"""BigQuery asynchronous backend implementation."""
import logging
from typing import Any, Dict, List, Optional, Tuple
from typing import Any, Dict, Optional, Tuple

from rhosocial.activerecord.backend.base import AsyncStorageBackend
from rhosocial.activerecord.backend.base.execution_logging import (
log_execution_error, log_execution_start,
)
from rhosocial.activerecord.backend.errors import ConnectionError, DatabaseError
from rhosocial.activerecord.backend.errors import DatabaseError
from rhosocial.activerecord.backend.options import ExecutionOptions, LogContext
from rhosocial.activerecord.backend.result import QueryResult
from rhosocial.activerecord.backend.schema import StatementType
Expand All @@ -18,7 +18,10 @@
from ..config import BigQueryConnectionConfig
from ..dialect import BigQueryDialect
from ..async_transaction import AsyncBigQueryTransactionManager
from .backend import log_result_summary
from .backend import (
_classify_bigquery_error, _translate_bigquery_error,
_translate_connect_error, _translate_job_errors, log_result_summary,
)
from .qualified_names import BigQueryQualifiedNameMixin


Expand Down Expand Up @@ -85,7 +88,9 @@ async def connect(self) -> None:
self._connected = True
self.log(logging.DEBUG, "Connected to BigQuery")
except Exception as e:
raise ConnectionError(f"Failed to connect to BigQuery: {e}") from e
# The connection phase always surfaces ``ConnectionError``; the
# translator only decides whether the failure is transient.
raise _translate_connect_error(e) from e

async def disconnect(self) -> None:
if hasattr(self, '_client') and self._client:
Expand Down Expand Up @@ -149,6 +154,18 @@ async def execute(
is replaced (there is no DB-API cursor), so this method emits the
shared ``DEBUG`` SQL record and ``INFO`` summary through
:mod:`rhosocial.activerecord.backend.base.execution_logging`.

Transaction semantics: every BigQuery query job commits its own
statement asynchronously, so there is no connection-level
commit/autocommit hook and the auto-commit phase cannot raise
``TransactionError``. Explicit transactions issue ``BEGIN
TRANSACTION`` / ``COMMIT TRANSACTION`` through this method (see
:class:`~..async_transaction.AsyncBigQueryTransactionManager`).

Raises:
DatabaseError: the phase-specific taxonomy error: driver
failures are mapped by :func:`_translate_bigquery_error` and
framework errors pass through unchanged.
"""
from .backend import BigQueryBackend

Expand Down Expand Up @@ -183,7 +200,9 @@ async def execute(
exc = job.exception()
if exc is not None:
raise exc
raise DatabaseError(str(job.errors))
# No driver exception to chain (emulator-style job errors):
# classify the message like a status-less driver reply.
raise _translate_job_errors(job.errors)
if is_select:
rows_iter = job.result()
# See BigQueryBackend.execute: rows are converted to dicts and
Expand All @@ -207,6 +226,9 @@ async def execute(
except Exception as e:
log_execution_error(self, e, options)
self._handle_error(e)
# _handle_error() is required to raise. The fallback keeps a
# backend that forgot from turning a failure into a None result.
raise DatabaseError(str(e)) from e

# -- Client-side PK generation (see BigQueryBackend for rationale) ----------

Expand Down Expand Up @@ -294,7 +316,7 @@ async def bulk_update(self, options) -> QueryResult:
assignments = {}
for field_name, values in options.field_values.items():
cases = []
for pk_val, field_val in zip(options.pk_values, values):
for pk_val, field_val in zip(options.pk_values, values): # noqa: B905 - Python 3.8 has no zip(strict=)
condition = ComparisonPredicate(self.dialect, "=", pk_col, Literal(self.dialect, pk_val))
result_expr = Literal(self.dialect, field_val)
cases.append((condition, result_expr))
Expand Down Expand Up @@ -370,62 +392,34 @@ async def delete(self, options) -> QueryResult:
return result

def _handle_error(self, error: Exception) -> None:
from rhosocial.activerecord.backend.errors import (
ConnectionError, IntegrityError, QueryError, DatabaseError,
)
category = self._classify_error(error)
if category == 'connection':
raise ConnectionError(str(error)) from error
elif category == 'integrity':
raise IntegrityError(str(error)) from error
elif category == 'query':
raise QueryError(str(error)) from error
else:
raise DatabaseError(str(error)) from error
"""Translate a driver exception into a taxonomy error and raise it.

Delegates to :func:`~.backend._translate_bigquery_error` so the sync
and async backends classify identically; the original driver error
stays chained as the cause. Framework errors are re-raised unchanged,
preserving their phase-specific type.
"""
if isinstance(error, DatabaseError):
raise error
raise _translate_bigquery_error(error) from error

async def _handle_auto_commit(self) -> None:
"""No-op: a BigQuery query job commits its own statement.

There is no connection-level commit or autocommit hook, so the
auto-commit phase has no failure mode and never raises
``TransactionError``. Explicit transactions are driven with
``BEGIN TRANSACTION`` / ``COMMIT TRANSACTION`` statements by
:class:`~..async_transaction.AsyncBigQueryTransactionManager`.
"""

def _classify_error(self, error: Exception) -> str:
"""Classify a google-cloud / google-api-core exception into an
error category used by :meth:`_handle_error`.
"""Return the legacy category name for *error*.

Returns one of: 'connection', 'integrity', 'query', 'generic'.
Kept for callers that used this method before the taxonomy split;
:func:`~.backend._translate_bigquery_error` is authoritative.
"""
try:
from google.api_core import exceptions as gexc
except ImportError: # google-cloud-bigquery not installed
return 'generic'

if isinstance(error, gexc.InternalServerError):
# BigQuery (and the emulator) often reports query analysis /
# execution failures as jobInternalError 500s; only treat a bare
# internal error as a connection problem.
msg = str(error).lower()
integrity_hints = ('constraint failed', 'duplicate', 'not null',
'primary key', 'foreign key', 'unique')
query_hints = ('failed to analyze', 'failed to parse', 'failed to exec',
'not found', 'syntax error', 'unrecognized', 'invalid',
'type mismatch', 'no such table', 'already exists')
if any(h in msg for h in integrity_hints):
return 'integrity'
if any(h in msg for h in query_hints):
return 'query'
return 'connection'
if isinstance(error, (
gexc.ServiceUnavailable, gexc.DeadlineExceeded, gexc.GatewayTimeout,
gexc.Aborted, gexc.Unauthenticated,
gexc.ResourceExhausted, gexc.TooManyRequests,
)):
return 'connection'
if isinstance(error, (gexc.AlreadyExists, gexc.FailedPrecondition,
gexc.PermissionDenied)):
return 'integrity'
if isinstance(error, (gexc.BadRequest, gexc.InvalidArgument,
gexc.NotFound)):
msg = str(error).lower()
if any(k in msg for k in ('duplicate', 'constraint', 'primary key',
'foreign key', 'not null')):
return 'integrity'
return 'query'
return 'generic'
return _classify_bigquery_error(error)

def get_server_version(self) -> Tuple[int, ...]:
return self._version
Expand Down
Loading
Loading