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

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:
2026-05-31 19:21:50 -04:00
committed by drew
parent 21c617e1cc
commit ad1b30b370
3 changed files with 103 additions and 76 deletions
+1
View File
@@ -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
+15
View File
@@ -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