Skip to content
7 changes: 7 additions & 0 deletions changelog/572.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
Tracking groups now reconcile correctly when a run saves no nodes at all. Previously `update_group()` returned early on an empty member list, so a generator that produced nothing (a decommissioning run) or a repository whose last object file was removed left every previously tracked node behind as an orphan, still listed in the group. A run that tracks nothing but has an existing group now prunes it; a run that tracks nothing and has no group still creates none.

Cleanup is also no longer aborted by a single refused delete. `delete_unused()` attempts every unused member and reports the refusals together as `TrackingGroupCleanupError` instead of propagating the first `GraphQLError` and silently skipping the rest. Members that could not be deleted are kept in the tracking group so a later run retries them, and the sync client now has the same error tolerance as the async one.

A failure that is not a refusal, such as a timeout or an expired token, is not a fact about the member being deleted: it stops the cleanup and is reported as itself rather than recorded against every remaining member in turn. The tracking group is still written before that failure surfaces, listing the members the cleanup did not get through along with the nodes the run created, so an interrupted cleanup can no longer leave those nodes in no group at all. `delete_unused()` returns a `ReapResult` describing all three outcomes rather than a bare mapping of failures.

`TrackingGroupCleanupError` reports only the server's reason for each member rather than the full mutation that was attempted, `infrahubctl` renders it as a table instead of a traceback, and the exception can be serialized so it survives a task orchestrator's failure handling.
22 changes: 14 additions & 8 deletions infrahub_sdk/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2295,10 +2295,13 @@ async def __aexit__(
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if exc_type is None and self.mode == InfrahubClientMode.TRACKING:
await self.group_context.update_group()

self.mode = InfrahubClientMode.DEFAULT
try:
if exc_type is None and self.mode == InfrahubClientMode.TRACKING:
await self.group_context.update_group()
finally:
# update_group() can raise, and leaving the client in tracking mode would
# silently enroll every later save into the stale context.
self.mode = InfrahubClientMode.DEFAULT

async def convert_object_type(
self,
Expand Down Expand Up @@ -4090,10 +4093,13 @@ def __exit__(
exc_value: BaseException | None,
traceback: TracebackType | None,
) -> None:
if exc_type is None and self.mode == InfrahubClientMode.TRACKING:
self.group_context.update_group()

self.mode = InfrahubClientMode.DEFAULT
try:
if exc_type is None and self.mode == InfrahubClientMode.TRACKING:
self.group_context.update_group()
finally:
# update_group() can raise, and leaving the client in tracking mode would
# silently enroll every later save into the stale context.
self.mode = InfrahubClientMode.DEFAULT

def convert_object_type(
self,
Expand Down
16 changes: 16 additions & 0 deletions infrahub_sdk/ctl/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from rich.console import Console
from rich.logging import RichHandler
from rich.markup import escape
from rich.table import Table

from ..exceptions import (
AuthenticationError,
Expand All @@ -25,6 +26,7 @@
SchemaNotFoundError,
ServerNotReachableError,
ServerNotResponsiveError,
TrackingGroupCleanupError,
ValidationError,
)
from ..graphql.query_renderer import render_query
Expand Down Expand Up @@ -67,6 +69,9 @@ def handle_exception(exc: Exception, console: Console, exit_code: int) -> NoRetu
if isinstance(exc, GraphQLError):
print_graphql_errors(console=console, errors=exc.errors)
raise typer.Exit(code=exit_code)
if isinstance(exc, TrackingGroupCleanupError):
print_tracking_group_failures(console=console, failures=exc.failures)
raise typer.Exit(code=exit_code)
if isinstance(exc, (SchemaNotFoundError, NodeNotFoundError, ResourceNotDefinedError, GraphQLQueryError)):
console.print(f"[red]Error: {exc!s}")
raise typer.Exit(code=exit_code)
Expand Down Expand Up @@ -148,6 +153,17 @@ def print_graphql_errors(console: Console, errors: list) -> None:
console.print(f"[red]{escape(str(error))}")


def print_tracking_group_failures(console: Console, failures: dict[str, str]) -> None:
table = Table(title="Unused tracking group members that could not be deleted")
table.add_column("Node ID")
table.add_column("Reason")
for node_id, reason in failures.items():
table.add_row(escape(node_id), escape(reason))

console.print(table)
console.print("[yellow]These nodes remain members of the tracking group and will be retried on the next run.")


def parse_cli_vars(variables: list[str] | None) -> dict[str, str]:
if not variables:
return {}
Expand Down
18 changes: 18 additions & 0 deletions infrahub_sdk/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ def __init__(self, errors: list[dict[str, Any]], query: str | None = None, varia
super().__init__(self.message)


class TrackingGroupCleanupError(Error):
"""Raised when unused members of a tracking group could not be deleted.

Every unused member is attempted before this is raised, and the ones that failed are
kept in the tracking group so a later run retries them.
"""

def __init__(self, failures: dict[str, str]) -> None:
self.failures = failures
details = "; ".join(f"{node_id} ({reason})" for node_id, reason in failures.items())
super().__init__(f"Unable to delete {len(failures)} unused member(s) of the tracking group: {details}")

def __reduce__(self) -> tuple[type[TrackingGroupCleanupError], tuple[dict[str, str]]]:
# Rebuild from the failures rather than the formatted message, so the exception
# survives the serialization that a task orchestrator applies to a failed run.
return (self.__class__, (self.failures,))


class VersionNotSupportedError(Error):
"""Raised when a feature is used against an Infrahub server version that does not support it."""

Expand Down
Loading
Loading