fix(data-integrity): remove session.rollback() calls from ProjectRepository

Extends the data integrity fix to all repository methods that receive sessions
from a shared UnitOfWork (via constructor injection or session factory).

Calling session.rollback() on an externally-owned session rolls back ALL pending
writes in the shared transaction, causing silent data loss when multiple
repositories participate in the same UnitOfWork.

Changes:
- ProjectRepository.create(): replaced # type: ignore with cast(int, ...)
- ActionRepository.create/update/delete(): removed session.rollback() calls
- LifecyclePlanRepository.create/update/delete(): removed session.rollback() calls
- ResourceRepository.create/update/delete/link_child/unlink_child/auto_discover_children():
  removed session.rollback() calls
- Added TDD tests (features/tdd_data_integrity_session_rollback_7489.feature)
  verifying repositories do NOT call rollback on shared sessions
- Updated CHANGELOG.md and CONTRIBUTORS.md

ISSUES CLOSED: #7489
This commit is contained in:
2026-05-04 22:24:11 +00:00
committed by Forgejo
parent dba9848c10
commit 2e3b186877
5 changed files with 417 additions and 17 deletions
+13
View File
@@ -15,6 +15,19 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
versions (<3.13.4) cannot be installed even if upstream transitive dependencies have loose
version constraints.
### Fixed
- **Data integrity: removed session.rollback() calls from repository methods using shared sessions** (#7489):
Repository methods that receive a session from a shared `UnitOfWork` (via constructor injection
or session factory) were incorrectly calling `session.rollback()` when catching database errors.
This caused silent data loss: rolling back a shared session discards all pending writes from
other repositories in the same UnitOfWork transaction. Fixed by removing `session.rollback()`
calls from `ProjectRepository.create()`, `ActionRepository.create/update/delete()`,
`LifecyclePlanRepository.create/update/delete()`, and `ResourceRepository.create/update/delete/link_child/unlink_child/auto_discover_children()`.
The UnitOfWork is now the sole owner of the session and is responsible for transaction lifecycle
management (commit/rollback). Also replaced `# type: ignore` suppression in
`ProjectRepository.create()` with a proper `cast(int, ...)` call.
### Changed
- Restored `benchmark-regression` CI job to `master.yml` with `pull_request` trigger guard
+1
View File
@@ -26,3 +26,4 @@ Below are some of the specific details of various contributions.
* HAL 9000 has contributed automated bug fixes, CLI output formatting improvements, and ongoing maintenance as part of the CleverAgents automation system.
* HAL 9000 has contributed the file edit encoding parameter fix (PR #8258 / issue #7559).
* HAL 9000 has contributed comprehensive milestone documentation for v3.6.0 (Advanced Concepts & Deferred Features) and v3.7.0 (TUI Implementation) (PR #9903): split into sub-documents covering context strategies, LLM backends, resource types, A2A rename, container tool execution, scope chain resolution, cost/safety budgets, E2E workflow tests, code review examples, plugin architecture, TUI layout, persona system, reference/command input, session management, configuration, and TuiMaterializer integration.
* HAL 9000 has contributed the data integrity fix for issue #7489 (PR #8179): removed incorrect `session.rollback()` calls from repository methods that receive sessions from a shared UnitOfWork, preventing silent data loss when multiple repositories participate in the same transaction.
@@ -0,0 +1,330 @@
"""Step definitions for TDD issue #7489: repository methods must not call
session.rollback() on externally-owned sessions.
These tests verify that the fix for issue #7489 is in place: repository
methods that receive a session from a UnitOfWork (via constructor injection
or session factory) must NOT call session.rollback() when catching database
errors, because the UoW owns the session and is responsible for rollback.
"""
from __future__ import annotations
from datetime import UTC, datetime
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
from behave import given, then, when
from behave.runner import Context
from sqlalchemy.exc import IntegrityError, OperationalError
from cleveragents.core.exceptions import DatabaseError
from cleveragents.infrastructure.database.repositories import (
ActionRepository,
DuplicateActionError,
DuplicatePlanError,
LifecyclePlanRepository,
ProjectRepository,
ResourceRepository,
)
# ---------------------------------------------------------------------------
# Shared mock session setup
# ---------------------------------------------------------------------------
@given("a shared mock session that tracks rollback calls")
def step_shared_mock_session(context: Context) -> None:
"""Create a shared mock session that tracks rollback calls."""
mock = MagicMock()
mock.rollback.return_value = None
mock.add.return_value = None
mock.flush.return_value = None
mock.refresh.return_value = None
mock.commit.return_value = None
mock.close.return_value = None
# Default query returns empty result
query_mock = MagicMock()
query_mock.filter_by.return_value.first.return_value = None
query_mock.filter.return_value.first.return_value = None
mock.query.return_value = query_mock
context.shared_session = mock
context.repo_error = None
@given("the shared session raises OperationalError on flush")
def step_session_raises_op_error_on_flush(context: Context) -> None:
"""Configure the shared session to raise OperationalError on flush."""
context.shared_session.flush.side_effect = OperationalError(
"disk I/O error", {}, None
)
@given("the shared session raises IntegrityError on flush")
def step_session_raises_integrity_error_on_flush(context: Context) -> None:
"""Configure the shared session to raise IntegrityError on flush."""
context.shared_session.flush.side_effect = IntegrityError(
"UNIQUE constraint failed: table.name", {}, None
)
# ---------------------------------------------------------------------------
# Repository setup steps
# ---------------------------------------------------------------------------
@given("a ProjectRepository using the shared session")
def step_project_repo_with_shared_session(context: Context) -> None:
"""Create a ProjectRepository with the shared session."""
context.repo_under_test = ProjectRepository(session=context.shared_session)
@given("an ActionRepository using the shared session factory")
def step_action_repo_with_shared_session(context: Context) -> None:
"""Create an ActionRepository with a factory returning the shared session."""
context.repo_under_test = ActionRepository(
session_factory=lambda: context.shared_session
)
@given("a LifecyclePlanRepository using the shared session factory")
def step_plan_repo_with_shared_session(context: Context) -> None:
"""Create a LifecyclePlanRepository with a factory returning the shared session."""
context.repo_under_test = LifecyclePlanRepository(
session_factory=lambda: context.shared_session
)
@given("a ResourceRepository using the shared session factory")
def step_resource_repo_with_shared_session(context: Context) -> None:
"""Create a ResourceRepository with a factory returning the shared session."""
context.repo_under_test = ResourceRepository(
session_factory=lambda: context.shared_session
)
# ---------------------------------------------------------------------------
# Action steps
# ---------------------------------------------------------------------------
def _make_fake_project() -> Any:
"""Create a minimal fake Project domain object."""
from pathlib import Path
from cleveragents.domain.models.core import Project, ProjectSettings
return Project(
name="test-project",
path=Path("/tmp/test"),
settings=ProjectSettings(),
)
def _make_fake_action() -> Any:
"""Create a minimal fake Action domain object."""
now = datetime.now(tz=UTC)
return SimpleNamespace(
namespaced_name=type(
"_NS",
(),
{
"namespace": "local",
"name": "test-action",
"__str__": lambda s: "local/test-action",
},
)(),
description="A test action",
long_description=None,
definition_of_done="Done",
strategy_actor="local/strategist",
execution_actor="local/executor",
estimation_actor=None,
review_actor=None,
apply_actor=None,
invariant_actor=None,
automation_profile=None,
inputs_schema=None,
state=SimpleNamespace(value="available"),
reusable=False,
read_only=False,
created_by=None,
tags=[],
arguments=[],
invariants=[],
created_at=now,
updated_at=now,
)
def _make_fake_plan() -> Any:
"""Create a minimal fake Plan domain object."""
now = datetime.now(tz=UTC)
return SimpleNamespace(
identity=SimpleNamespace(
plan_id="01HGZ6FE0A0000000000000099",
parent_plan_id=None,
root_plan_id=None,
attempt=1,
),
phase=SimpleNamespace(value="strategize"),
processing_state=SimpleNamespace(value="queued"),
namespaced_name=type(
"_NS", (), {"namespace": "local", "__str__": lambda s: "local/test-plan"}
)(),
namespace="local",
action_name="local/test-action",
description="A test plan",
definition_of_done="Done",
strategy_actor="local/strategist",
execution_actor="local/executor",
review_actor=None,
apply_actor=None,
estimation_actor=None,
invariant_actor=None,
automation_profile=None,
error_message=None,
error_details=None,
changeset_id=None,
sandbox_refs=[],
validation_summary=None,
decision_root_id=None,
execution_environment=None,
execution_env_priority=None,
reusable=False,
read_only=False,
created_by=None,
tags=[],
project_links=[],
arguments={},
arguments_order=[],
invariants=[],
effective_profile_snapshot="{}",
timestamps=SimpleNamespace(
created_at=now,
updated_at=now,
applied_at=None,
strategize_started_at=None,
strategize_completed_at=None,
execute_started_at=None,
execute_completed_at=None,
apply_started_at=None,
),
reversion_count=0,
last_completed_step=None,
last_checkpoint_id=None,
cost_estimate_usd=None,
estimation_report=None,
)
def _make_fake_resource() -> Any:
"""Create a minimal fake Resource domain object."""
return SimpleNamespace(
resource_id="01HGZ6FE0A0000000000000001",
name="test/my-resource",
resource_type_name="test/resource-type",
classification="physical",
description="A test resource",
location="/tmp/test",
capabilities=SimpleNamespace(writable=True),
sandbox_strategy=None,
content_hash=None,
properties=None,
)
@when("I attempt to create a project via ProjectRepository")
def step_create_project(context: Context) -> None:
"""Attempt to create a project, capturing any error."""
try:
context.repo_under_test.create(_make_fake_project())
except Exception as exc:
context.repo_error = exc
@when("I attempt to create an action via ActionRepository")
def step_create_action(context: Context) -> None:
"""Attempt to create an action, capturing any error."""
try:
context.repo_under_test.create(_make_fake_action())
except Exception as exc:
context.repo_error = exc
@when("I attempt to create a plan via LifecyclePlanRepository")
def step_create_plan(context: Context) -> None:
"""Attempt to create a plan, capturing any error."""
try:
context.repo_under_test.create(_make_fake_plan())
except Exception as exc:
context.repo_error = exc
@when("I attempt to create a resource via ResourceRepository")
def step_create_resource(context: Context) -> None:
"""Attempt to create a resource, capturing any error."""
# Configure the session to return a resource type row (so type validation passes)
type_row_mock = MagicMock()
type_row_mock.name = "test/resource-type"
query_mock = MagicMock()
# First query (for resource type) returns the type row
# Second query (for existing resource) returns None
query_mock.filter_by.return_value.first.side_effect = [type_row_mock, None]
context.shared_session.query.return_value = query_mock
try:
context.repo_under_test.create(_make_fake_resource())
except Exception as exc:
context.repo_error = exc
# ---------------------------------------------------------------------------
# Assertion steps
# ---------------------------------------------------------------------------
@then("a DatabaseError should be raised")
def step_check_database_error(context: Context) -> None:
"""Verify that a DatabaseError was raised."""
assert context.repo_error is not None, (
"Expected a DatabaseError but no error was raised"
)
assert isinstance(context.repo_error, DatabaseError), (
f"Expected DatabaseError, got {type(context.repo_error).__name__}: "
f"{context.repo_error}"
)
@then("a DatabaseError or DuplicateActionError should be raised")
def step_check_database_or_duplicate_action_error(context: Context) -> None:
"""Verify that a DatabaseError or DuplicateActionError was raised."""
assert context.repo_error is not None, (
"Expected a DatabaseError or DuplicateActionError but no error was raised"
)
assert isinstance(context.repo_error, (DatabaseError, DuplicateActionError)), (
f"Expected DatabaseError or DuplicateActionError, "
f"got {type(context.repo_error).__name__}: {context.repo_error}"
)
@then("a DatabaseError or DuplicatePlanError should be raised")
def step_check_database_or_duplicate_plan_error(context: Context) -> None:
"""Verify that a DatabaseError or DuplicatePlanError was raised."""
assert context.repo_error is not None, (
"Expected a DatabaseError or DuplicatePlanError but no error was raised"
)
assert isinstance(context.repo_error, (DatabaseError, DuplicatePlanError)), (
f"Expected DatabaseError or DuplicatePlanError, "
f"got {type(context.repo_error).__name__}: {context.repo_error}"
)
@then("the shared session rollback should NOT have been called")
def step_check_no_rollback(context: Context) -> None:
"""Verify that session.rollback() was NOT called on the shared session."""
assert not context.shared_session.rollback.called, (
f"Expected session.rollback() to NOT be called, but it was called "
f"{context.shared_session.rollback.call_count} time(s). "
f"Calls: {context.shared_session.rollback.call_args_list}"
)
@@ -0,0 +1,56 @@
@tdd_issue @tdd_issue_7489
Feature: Repository methods do not call session.rollback() on externally-owned sessions
As a system using the UnitOfWork pattern for transaction management
I want repository methods to NOT call session.rollback() on shared sessions
So that previously flushed writes from other repositories are not silently discarded
Background:
Given a shared mock session that tracks rollback calls
@tdd_issue @tdd_issue_7489
Scenario: ProjectRepository.create() does not rollback the shared session on OperationalError
Given a ProjectRepository using the shared session
And the shared session raises OperationalError on flush
When I attempt to create a project via ProjectRepository
Then a DatabaseError should be raised
And the shared session rollback should NOT have been called
@tdd_issue @tdd_issue_7489
Scenario: ActionRepository.create() does not rollback the shared session on OperationalError
Given an ActionRepository using the shared session factory
And the shared session raises OperationalError on flush
When I attempt to create an action via ActionRepository
Then a DatabaseError should be raised
And the shared session rollback should NOT have been called
@tdd_issue @tdd_issue_7489
Scenario: LifecyclePlanRepository.create() does not rollback the shared session on OperationalError
Given a LifecyclePlanRepository using the shared session factory
And the shared session raises OperationalError on flush
When I attempt to create a plan via LifecyclePlanRepository
Then a DatabaseError should be raised
And the shared session rollback should NOT have been called
@tdd_issue @tdd_issue_7489
Scenario: ResourceRepository.create() does not rollback the shared session on OperationalError
Given a ResourceRepository using the shared session factory
And the shared session raises OperationalError on flush
When I attempt to create a resource via ResourceRepository
Then a DatabaseError should be raised
And the shared session rollback should NOT have been called
@tdd_issue @tdd_issue_7489
Scenario: ActionRepository.create() does not rollback the shared session on IntegrityError
Given an ActionRepository using the shared session factory
And the shared session raises IntegrityError on flush
When I attempt to create an action via ActionRepository
Then a DatabaseError or DuplicateActionError should be raised
And the shared session rollback should NOT have been called
@tdd_issue @tdd_issue_7489
Scenario: LifecyclePlanRepository.create() does not rollback the shared session on IntegrityError
Given a LifecyclePlanRepository using the shared session factory
And the shared session raises IntegrityError on flush
When I attempt to create a plan via LifecyclePlanRepository
Then a DatabaseError or DuplicatePlanError should be raised
And the shared session rollback should NOT have been called
@@ -171,7 +171,7 @@ class ProjectRepository:
self.session.flush()
self.session.refresh(db_project)
project.id = db_project.id # type: ignore
project.id = cast(int, db_project.id)
return project
except (OperationalError, SQLAlchemyDatabaseError) as e:
raise DatabaseError(f"Failed to create project: {e}") from e
@@ -994,13 +994,13 @@ class ActionRepository(ActionRepositoryProtocol):
session.flush()
return action
except IntegrityError as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
# Unique constraint on ``name`` column
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
raise DuplicateActionError(str(action.namespaced_name)) from exc
raise DatabaseError(f"Failed to create action: {exc}") from exc
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(f"Failed to create action: {exc}") from exc
@database_retry
@@ -1242,7 +1242,7 @@ class ActionRepository(ActionRepositoryProtocol):
session.flush()
return action
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to update action {action_name_str}: {exc}"
) from exc
@@ -1317,7 +1317,7 @@ class ActionRepository(ActionRepositoryProtocol):
except ActionInUseError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to delete action {action_name}: {exc}"
) from exc
@@ -1387,13 +1387,13 @@ class LifecyclePlanRepository(LifecyclePlanRepositoryProtocol):
session.flush()
return plan
except IntegrityError as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
exc_str = str(exc).upper()
if "UNIQUE" in exc_str or "PRIMARY" in exc_str:
raise DuplicatePlanError(str(plan.identity.plan_id)) from exc
raise DatabaseError(f"Failed to create plan: {exc}") from exc
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(f"Failed to create plan: {exc}") from exc
@database_retry
@@ -1603,7 +1603,7 @@ class LifecyclePlanRepository(LifecyclePlanRepositoryProtocol):
except PlanNotFoundError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(f"Failed to update plan {plan_id_str}: {exc}") from exc
@database_retry
@@ -1647,7 +1647,7 @@ class LifecyclePlanRepository(LifecyclePlanRepositoryProtocol):
session.flush()
return True
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(f"Failed to delete plan {plan_id}: {exc}") from exc
@database_retry
@@ -2259,14 +2259,14 @@ class ResourceRepository:
except (ResourceTypeNotFoundError, DuplicateResourceError):
raise
except IntegrityError as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
if "UNIQUE" in str(exc).upper() or "unique" in str(exc).lower():
raise DuplicateResourceError(
resource.name or resource.resource_id
) from exc
raise DatabaseError(f"Failed to create resource: {exc}") from exc
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(f"Failed to create resource: {exc}") from exc
@database_retry
@@ -2395,7 +2395,7 @@ class ResourceRepository:
except ResourceNotFoundRepoError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to update resource '{resource.resource_id}': {exc}"
) from exc
@@ -2440,7 +2440,7 @@ class ResourceRepository:
except ResourceHasEdgesError:
raise
except (OperationalError, SQLAlchemyDatabaseError) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to delete resource '{resource_id}': {exc}"
) from exc
@@ -2532,7 +2532,7 @@ class ResourceRepository:
):
raise
except IntegrityError as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to link {parent_id} -> {child_id}: {exc}"
) from exc
@@ -2540,7 +2540,7 @@ class ResourceRepository:
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to link {parent_id} -> {child_id}: {exc}"
) from exc
@@ -2591,7 +2591,7 @@ class ResourceRepository:
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to unlink {parent_id} -> {child_id}: {exc}"
) from exc
@@ -2802,7 +2802,7 @@ class ResourceRepository:
OperationalError,
SQLAlchemyDatabaseError,
) as exc:
session.rollback()
# Do NOT call session.rollback() — the UoW owns this session
raise DatabaseError(
f"Failed to auto-discover children for '{resource_id}': {exc}"
) from exc