fix(resource): preserve atomicity in register_resource without breaking shared-session callers
CI / lint (pull_request) Successful in 38s
CI / typecheck (pull_request) Successful in 1m7s
CI / quality (pull_request) Successful in 51s
CI / build (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 35s
CI / security (pull_request) Successful in 1m54s
CI / push-validation (pull_request) Successful in 30s
CI / unit_tests (pull_request) Successful in 6m28s
CI / docker (pull_request) Successful in 1m29s
CI / integration_tests (pull_request) Successful in 21m54s
CI / coverage (pull_request) Failing after 14m24s
CI / status-check (pull_request) Failing after 3s
CI / lint (pull_request) Successful in 38s
CI / typecheck (pull_request) Successful in 1m7s
CI / quality (pull_request) Successful in 51s
CI / build (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 35s
CI / security (pull_request) Successful in 1m54s
CI / push-validation (pull_request) Successful in 30s
CI / unit_tests (pull_request) Successful in 6m28s
CI / docker (pull_request) Successful in 1m29s
CI / integration_tests (pull_request) Successful in 21m54s
CI / coverage (pull_request) Failing after 14m24s
CI / status-check (pull_request) Failing after 3s
The previous attempt wrapped `register_resource` in `with session.begin():` to guarantee parent + auto-discovered children commit atomically. That pattern raises `sqlalchemy.exc.InvalidRequestError: A transaction is already begun on this Session.` whenever a caller (e.g. the WF05 integration helper at `robot/helper_int_wf05_db_migration.py`) reuses a single Session across multiple service calls — autobegin has already opened the implicit transaction by the time `register_resource` runs. This rewrites the flow to keep the simpler `session.commit()` pattern that worked with shared sessions, but moves the commit to AFTER `auto_discover_children` so any failure between `session.add(parent)` and `session.commit()` rolls the whole transaction back via the existing `except`/`session.rollback()` handlers. Atomicity is preserved (parent is never persisted on an auto-discovery failure) and shared-session callers no longer get the `InvalidRequestError`. Also stabilises the `Service get_children returns auto-discovered children for directory` scenario in `features/resource_cli_tree.feature` by adding an explicit `Given a seeded directory exists at "/tmp/gcl"` step that creates the directory and writes a sentinel file. Without this seed the scenario depended on whatever happened to exist at `/tmp/gcl` in the CI environment. ISSUES CLOSED: #6464
This commit is contained in:
@@ -221,6 +221,7 @@ Feature: Resource CLI tree, inspect, link-child, and unlink-child commands
|
||||
|
||||
Scenario: Service get_children returns auto-discovered children for directory
|
||||
Given resource tree built-in types are bootstrapped
|
||||
And a seeded directory exists at "/tmp/gcl"
|
||||
And a resource tree resource "fs-directory" named "local/gc-leaf" at "/tmp/gcl"
|
||||
When I get children of resource "local/gc-leaf"
|
||||
Then the children list should have at least 1 child
|
||||
|
||||
@@ -149,6 +149,21 @@ def step_tree_create_temp_file(context: Context, filename: str, content: str) ->
|
||||
filepath.write_text(content, encoding="utf-8")
|
||||
|
||||
|
||||
@given('a seeded directory exists at "{path}"')
|
||||
def step_tree_seed_directory(context: Context, path: str) -> None:
|
||||
"""Create the directory and drop a sentinel file inside.
|
||||
|
||||
Used by scenarios that exercise filesystem auto-discovery on an
|
||||
``fs-directory`` resource — without an explicit seed the scenario
|
||||
would be CI-environment-dependent (empty / missing ``path`` ⇒
|
||||
zero discovered children ⇒ flake).
|
||||
"""
|
||||
target = Path(path)
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
sentinel = target / ".cleveragents-tree-sentinel"
|
||||
sentinel.write_text("seed", encoding="utf-8")
|
||||
|
||||
|
||||
@given('resource tree child "{child}" is linked to parent "{parent}"')
|
||||
def step_tree_link_child(context: Context, child: str, parent: str) -> None:
|
||||
"""Link a child to a parent."""
|
||||
|
||||
@@ -101,85 +101,96 @@ class ResourceInstanceMixin:
|
||||
"""
|
||||
session = self._session()
|
||||
try:
|
||||
with session.begin():
|
||||
type_row = (
|
||||
session.query(ResourceTypeModel).filter_by(name=type_name).first()
|
||||
)
|
||||
if type_row is None:
|
||||
raise NotFoundError(
|
||||
resource_type="resource_type",
|
||||
resource_id=type_name,
|
||||
)
|
||||
|
||||
if not type_row.user_addable:
|
||||
raise ValidationError(
|
||||
f"Resource type '{type_name}' is not user-addable. "
|
||||
"Only types with user_addable=true can be instantiated.",
|
||||
)
|
||||
|
||||
resource_kind_str: str = str(type_row.resource_kind)
|
||||
resource_id = str(ULID())
|
||||
|
||||
# Determine namespace from the resource name
|
||||
namespace: str | None = None
|
||||
if name is not None:
|
||||
parts = name.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
namespace = parts[0]
|
||||
|
||||
now_iso = datetime.now(tz=UTC).isoformat()
|
||||
properties_json: str | None = None
|
||||
if properties:
|
||||
properties_json = json.dumps(properties)
|
||||
|
||||
db_resource = ResourceModel(
|
||||
resource_id=resource_id,
|
||||
namespaced_name=name,
|
||||
namespace=namespace,
|
||||
type_name=type_name,
|
||||
resource_kind=resource_kind_str,
|
||||
location=location,
|
||||
description=description,
|
||||
read_only=read_only,
|
||||
auto_discovered=False,
|
||||
sandbox_strategy=None,
|
||||
content_hash=None,
|
||||
properties_json=properties_json,
|
||||
metadata_json=None,
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
type_row = (
|
||||
session.query(ResourceTypeModel).filter_by(name=type_name).first()
|
||||
)
|
||||
if type_row is None:
|
||||
raise NotFoundError(
|
||||
resource_type="resource_type",
|
||||
resource_id=type_name,
|
||||
)
|
||||
|
||||
session.add(db_resource)
|
||||
session.flush()
|
||||
|
||||
repository = ResourceRepository(self._session)
|
||||
try:
|
||||
repository.auto_discover_children(
|
||||
resource_id,
|
||||
session=session,
|
||||
commit=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Auto-discovery failed; rolling back parent resource",
|
||||
extra={"resource_id": resource_id, "type_name": type_name},
|
||||
)
|
||||
raise
|
||||
|
||||
return Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
resource_type_name=type_name,
|
||||
classification=PhysVirt(resource_kind_str),
|
||||
description=description,
|
||||
properties=properties or {},
|
||||
location=location,
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
created_at=datetime.fromisoformat(now_iso),
|
||||
updated_at=datetime.fromisoformat(now_iso),
|
||||
if not type_row.user_addable:
|
||||
raise ValidationError(
|
||||
f"Resource type '{type_name}' is not user-addable. "
|
||||
"Only types with user_addable=true can be instantiated.",
|
||||
)
|
||||
|
||||
resource_kind_str: str = str(type_row.resource_kind)
|
||||
resource_id = str(ULID())
|
||||
|
||||
# Determine namespace from the resource name
|
||||
namespace: str | None = None
|
||||
if name is not None:
|
||||
parts = name.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
namespace = parts[0]
|
||||
|
||||
now_iso = datetime.now(tz=UTC).isoformat()
|
||||
properties_json: str | None = None
|
||||
if properties:
|
||||
properties_json = json.dumps(properties)
|
||||
|
||||
db_resource = ResourceModel(
|
||||
resource_id=resource_id,
|
||||
namespaced_name=name,
|
||||
namespace=namespace,
|
||||
type_name=type_name,
|
||||
resource_kind=resource_kind_str,
|
||||
location=location,
|
||||
description=description,
|
||||
read_only=read_only,
|
||||
auto_discovered=False,
|
||||
sandbox_strategy=None,
|
||||
content_hash=None,
|
||||
properties_json=properties_json,
|
||||
metadata_json=None,
|
||||
created_at=now_iso,
|
||||
updated_at=now_iso,
|
||||
)
|
||||
|
||||
session.add(db_resource)
|
||||
session.flush()
|
||||
|
||||
# Run auto-discovery using the SAME session with commit=False so
|
||||
# the parent + every auto-discovered child are committed atomically
|
||||
# by the single ``session.commit()`` below. Any exception inside
|
||||
# auto_discover_children propagates to the outer ``except`` blocks,
|
||||
# which roll the whole transaction back — the parent is never
|
||||
# persisted on a failure. We intentionally avoid the
|
||||
# ``with session.begin():`` pattern here because callers (e.g.
|
||||
# the WF05 integration helper) reuse one Session across many ops
|
||||
# and that pattern raises ``InvalidRequestError`` when an outer
|
||||
# transaction is already begun.
|
||||
repository = ResourceRepository(self._session)
|
||||
try:
|
||||
repository.auto_discover_children(
|
||||
resource_id,
|
||||
session=session,
|
||||
commit=False,
|
||||
)
|
||||
except Exception:
|
||||
logger.error(
|
||||
"Auto-discovery failed; rolling back parent resource",
|
||||
extra={"resource_id": resource_id, "type_name": type_name},
|
||||
)
|
||||
raise
|
||||
|
||||
session.commit()
|
||||
|
||||
return Resource(
|
||||
resource_id=resource_id,
|
||||
name=name,
|
||||
resource_type_name=type_name,
|
||||
classification=PhysVirt(resource_kind_str),
|
||||
description=description,
|
||||
properties=properties or {},
|
||||
location=location,
|
||||
content_hash=None,
|
||||
sandbox_strategy=None,
|
||||
created_at=datetime.fromisoformat(now_iso),
|
||||
updated_at=datetime.fromisoformat(now_iso),
|
||||
)
|
||||
except (NotFoundError, ValidationError):
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
Reference in New Issue
Block a user