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
23 changes: 14 additions & 9 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Install dependencies
Expand Down Expand Up @@ -278,7 +278,7 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Install dependencies
Expand Down Expand Up @@ -385,7 +385,7 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Install dependencies
Expand Down Expand Up @@ -441,16 +441,21 @@ jobs:
export PYTHONPATH=src:tests
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup

- name: Run tests with coverage (only for postgres 18)
if: matrix.postgres-version == '18'
- name: Run tests with coverage (only for postgres 18 on Python 3.14)
if: matrix.postgres-version == '18' && matrix.python-version == '3.14'
run: |
export PYTHONPATH=src:tests
pip install codecov
python -m pytest tests/ --tb=short -n 8 --dist=loadgroup --cov=src --cov-report=xml
python -m pytest tests/ --tb=short -n auto --dist=loadgroup --cov=src/rhosocial --cov-report=xml

- name: Debug coverage report
if: matrix.postgres-version == '18' && matrix.python-version == '3.14'
run: |
python -m coverage report -m --include="src/rhosocial/activerecord/backend/impl/postgres/*" || true

- name: Upload coverage to Codecov
if: matrix.postgres-version == '18'
uses: codecov/codecov-action@v4
if: matrix.postgres-version == '18' && matrix.python-version == '3.14'
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage.xml
Expand Down Expand Up @@ -488,7 +493,7 @@ jobs:

- name: Install activerecord and testsuite
run: |
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/partition-maintenance" # NOSONAR
pip install "rhosocial-activerecord @ git+https://github.com/rhosocial/python-activerecord.git@feature/logging-execution" # NOSONAR
pip install "rhosocial-activerecord-testsuite @ git+https://github.com/rhosocial/python-activerecord-testsuite.git@release/v1.0.0.dev17"

- name: Install dependencies
Expand Down
1 change: 1 addition & 0 deletions changelog.d/68.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Adapted this backend to the core execution logging contract: structured execution summaries, shared sync/async logger ownership, redacted SQL logging, and bridges for the shared relation/query/basic contracts.
18 changes: 18 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Codecov status configuration.
#
# The CI uploads one report with the shared `unittests` flag plus the
# interpreter/backend flags. Status checks read the shared flag explicitly so
# they do not depend on whichever flag Codecov happens to pick as default.
coverage:
status:
project:
default:
target: auto
threshold: 0.2%
flags:
- unittests
patch:
default:
target: 75%
flags:
- unittests
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ markers = [
[tool.coverage.run]
source = ["rhosocial"]
branch = true
relative_files = true

[tool.coverage.report]
exclude_lines = [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@
)

from rhosocial.activerecord.backend.base import AsyncStorageBackend
from rhosocial.activerecord.backend.base.execution_logging import (
log_batch_start,
log_batch_summary,
log_execution_error,
)
from rhosocial.activerecord.backend.introspection.backend_mixin import IntrospectorBackendMixin
from rhosocial.activerecord.backend.explain import AsyncExplainBackendMixin
from rhosocial.activerecord.backend.errors import (
Expand All @@ -49,8 +54,9 @@
OperationalError,
QueryError,
)
from rhosocial.activerecord.backend.options import ExecutionOptions
from rhosocial.activerecord.backend.options import ExecutionOptions, StatementType
from rhosocial.activerecord.backend.result import QueryResult
from rhosocial.activerecord.logging.redaction import unwrap_sensitive
from ..config import PostgresConnectionConfig
from ..dialect import PostgresDialect
from .base import PostgresBackendMixin, PostgresConcurrencyMixin
Expand Down Expand Up @@ -176,7 +182,7 @@ def __init__(self, **kwargs: Any) -> None:
# Initialize transaction manager (will use backend.execute())
self._transaction_manager = AsyncPostgresTransactionManager(self, self.logger)

self.log(logging.INFO, "AsyncPostgresBackend initialized")
self.log(logging.DEBUG, "AsyncPostgresBackend initialized")

def _create_introspector(self) -> Any:
"""Create and return an AsyncPostgreSQLIntrospector with an async executor."""
Expand Down Expand Up @@ -267,7 +273,7 @@ async def connect(self) -> None:
f"Connected to PostgreSQL database: {self.config.host}:{self.config.port}/{self.config.database}",
)
except PsycopgError as e:
self.log(logging.ERROR, f"Failed to connect to PostgreSQL database: {str(e)}")
self.log(logging.DEBUG, f"Failed to connect to PostgreSQL database: {str(e)}")
raise ConnectionError(f"Failed to connect to PostgreSQL: {str(e)}") from e

async def disconnect(self) -> None:
Expand All @@ -282,7 +288,7 @@ async def disconnect(self) -> None:
self._connection = None
self.log(logging.INFO, "Disconnected from PostgreSQL database")
except PsycopgError as e:
self.log(logging.ERROR, f"Error during disconnection: {str(e)}")
self.log(logging.DEBUG, f"Error during disconnection: {str(e)}")
raise OperationalError(f"Error during PostgreSQL disconnection: {str(e)}") from e

async def _get_cursor(self) -> Any:
Expand Down Expand Up @@ -394,22 +400,32 @@ async def copy_from(
await self._try_rollback_transaction()
raise

async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult:
"""Execute the same SQL statement multiple times with different parameters asynchronously."""
async def execute_many(
self,
sql: str,
params_list: List[Tuple],
*,
options: Optional[ExecutionOptions] = None,
) -> QueryResult:
"""Execute the same SQL statement multiple times with different parameters.

The backend owns the canonical DEBUG batch record and INFO batch
summary; callers only pass the logging context through ``options``.
"""
if not self._connection:
await self.connect()

if options is None:
options = ExecutionOptions(stmt_type=StatementType.DML)
params_list = [unwrap_sensitive(params) for params in params_list]
log_batch_start(self, sql, params_list, options)

cursor = None
start_time = datetime.datetime.now()

try:
cursor = await self._get_cursor()

# Log the batch operation if logging is enabled
if getattr(self.config, "log_queries", False):
self.log(logging.DEBUG, f"Executing batch operation: {sql}")
self.log(logging.DEBUG, f"With {len(params_list)} parameter sets")

# Execute multiple statements
affected_rows = 0
for params in params_list:
Expand All @@ -420,20 +436,17 @@ async def execute_many(self, sql: str, params_list: List[Tuple]) -> QueryResult:

result = QueryResult(affected_rows=affected_rows, data=None, duration=duration)

self.log(
logging.INFO,
f"Batch operation completed, affected {affected_rows} rows, duration={duration:.3f}s",
)
log_batch_summary(self, sql, params_list, affected_rows, duration, options)
return result

except PsycopgIntegrityError as e:
self.log(logging.ERROR, f"Integrity error in batch: {str(e)}")
log_execution_error(self, e, options)
raise IntegrityError(str(e)) from e
except PsycopgError as e:
self.log(logging.ERROR, f"PostgreSQL error in batch: {str(e)}")
log_execution_error(self, e, options)
raise DatabaseError(str(e)) from e
except Exception as e:
self.log(logging.ERROR, f"Unexpected error during batch execution: {str(e)}")
log_execution_error(self, e, options)
raise QueryError(str(e)) from e
finally:
if cursor:
Expand Down Expand Up @@ -586,7 +599,7 @@ async def ping(self, reconnect: bool = True) -> bool:
await self.connect()
return True
except Exception as connect_error:
self.log(logging.ERROR, f"Failed to reconnect after ping failure: {str(connect_error)}")
self.log(logging.WARNING, f"Failed to reconnect after ping failure: {str(connect_error)}")
return False
return False

Expand All @@ -602,7 +615,7 @@ async def _reconnect(self) -> bool:
await self.connect()
return True
except Exception as e:
self.log(logging.ERROR, f"Failed to reconnect: {str(e)}")
self.log(logging.WARNING, f"Failed to reconnect: {str(e)}")
return False

async def execute(
Expand Down Expand Up @@ -630,8 +643,6 @@ async def execute(
Returns:
QueryResult containing the query results
"""
from rhosocial.activerecord.backend.options import StatementType

# If no options provided, create default options from kwargs
if options is None:
# Determine statement type based on SQL
Expand Down Expand Up @@ -675,7 +686,7 @@ async def execute(
if await self._reconnect():
continue
# If reconnection fails, break and raise the error
self.log(logging.ERROR, "Failed to reconnect after connection error")
self.log(logging.WARNING, "Failed to reconnect after connection error")
break
raise

Expand All @@ -689,30 +700,30 @@ async def _handle_error(self, error: Exception) -> None:

if isinstance(error, PsycopgIntegrityError):
if "duplicate key value violates unique constraint" in error_msg.lower():
self.log(logging.ERROR, f"Unique constraint violation: {error_msg}")
self.log(logging.DEBUG, f"Unique constraint violation: {error_msg}")
raise IntegrityError(f"Unique constraint violation: {error_msg}") from error
elif "violates foreign key constraint" in error_msg.lower():
self.log(logging.ERROR, f"Foreign key constraint violation: {error_msg}")
self.log(logging.DEBUG, f"Foreign key constraint violation: {error_msg}")
raise IntegrityError(f"Foreign key constraint violation: {error_msg}") from error
self.log(logging.ERROR, f"Integrity error: {error_msg}")
self.log(logging.DEBUG, f"Integrity error: {error_msg}")
raise IntegrityError(error_msg) from error
elif isinstance(error, PsycopgOperationalError):
if "deadlock detected" in error_msg.lower():
self.log(logging.ERROR, f"Deadlock error: {error_msg}")
self.log(logging.DEBUG, f"Deadlock error: {error_msg}")
raise DeadlockError(error_msg) from error
self.log(logging.ERROR, f"Operational error: {error_msg}")
self.log(logging.DEBUG, f"Operational error: {error_msg}")
raise OperationalError(error_msg) from error
elif isinstance(error, PsycopgProgrammingError):
self.log(logging.ERROR, f"Programming error: {error_msg}")
self.log(logging.DEBUG, f"Programming error: {error_msg}")
raise QueryError(error_msg) from error
elif isinstance(error, PsycopgDeadlockError):
self.log(logging.ERROR, f"Deadlock error: {error_msg}")
self.log(logging.DEBUG, f"Deadlock error: {error_msg}")
raise DeadlockError(error_msg) from error
elif isinstance(error, PsycopgError):
self.log(logging.ERROR, f"PostgreSQL error: {error_msg}")
self.log(logging.DEBUG, f"PostgreSQL error: {error_msg}")
raise DatabaseError(error_msg) from error
else:
self.log(logging.ERROR, f"Unexpected error: {error_msg}")
self.log(logging.DEBUG, f"Unexpected error: {error_msg}")
raise error

async def _try_rollback_transaction(self) -> None:
Expand All @@ -725,15 +736,15 @@ async def _try_rollback_transaction(self) -> None:
try:
# First, try the transaction manager if active
if self.in_transaction:
self.log(logging.INFO, "Attempting to rollback transaction after error")
self.log(logging.DEBUG, "Attempting to rollback transaction after error")
await self._transaction_manager.rollback()

# Also try direct connection rollback to handle edge cases
# This ensures we recover from aborted transaction state
if self._connection and not self._connection.closed:
try:
await self._connection.rollback()
self.log(logging.INFO, "Direct connection rollback completed")
self.log(logging.DEBUG, "Direct connection rollback completed")
except Exception as direct_error:
self.log(logging.DEBUG, f"Direct rollback not needed or failed: {direct_error}")
except Exception as rollback_error:
Expand Down
Loading
Loading