Repository navigation
Conversation
Codecov Report❌ Patch coverage is
@@ Coverage Diff @@
## infrahub-develop #1278 +/- ##
====================================================
- Coverage 87.00% 86.96% -0.05%
====================================================
Files 153 152 -1
Lines 16293 14805 -1488
Branches 2348 1989 -359
====================================================
- Hits 14176 12875 -1301
+ Misses 1494 1369 -125
+ Partials 623 561 -62
Flags with carried forward coverage won't be shown. Click here to find out more.
... and 13 files with indirect coverage changes 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
All reported issues were addressed across 4 files
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
Deploying infrahub-sdk-python with
|
| Latest commit: |
9d001e5
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://76cfc140.infrahub-sdk-python.pages.dev |
| Branch Preview URL: | https://po-tracking-group-zero-membe.infrahub-sdk-python.pages.dev |
ae12445 to
1dc8bad
Compare
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
aae78f5 to
1cbc949
Compare
There was a problem hiding this comment.
1 issue found across 7 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="infrahub_sdk/ctl/utils.py">
<violation number="1" location="infrahub_sdk/ctl/utils.py:72">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**
Add a CLI regression test for the new `TrackingGroupCleanupError` path, asserting that `handle_exception` renders every failed node and reason in the table and exits with the requested code; neither this branch nor `print_tracking_group_failures()` is currently exercised.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Re-trigger cubic
| if isinstance(exc, GraphQLError): | ||
| print_graphql_errors(console=console, errors=exc.errors) | ||
| raise typer.Exit(code=exit_code) | ||
| if isinstance(exc, TrackingGroupCleanupError): |
There was a problem hiding this comment.
P2: Custom agent: Flag AI Slop and Fabricated Changes
Add a CLI regression test for the new TrackingGroupCleanupError path, asserting that handle_exception renders every failed node and reason in the table and exits with the requested code; neither this branch nor print_tracking_group_failures() is currently exercised.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/ctl/utils.py, line 72:
<comment>Add a CLI regression test for the new `TrackingGroupCleanupError` path, asserting that `handle_exception` renders every failed node and reason in the table and exits with the requested code; neither this branch nor `print_tracking_group_failures()` is currently exercised.</comment>
<file context>
@@ -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)
</file context>
There was a problem hiding this comment.
Fixed in d33fdcd: test_a_tracking_group_cleanup_failure_lists_every_member_and_reason in tests/unit/ctl/test_utils.py asserts every node id and reason, the exit code, and that a bracketed reason is not read as markup.
This reply was written by an AI assistant (Claude Code).
1cbc949 to
d33fdcd
Compare
d33fdcd to
a42e040
Compare
update_group() returned early whenever a run tracked zero members, so it never diffed the previous membership against the empty set. A generator that legitimately produced nothing, or a repository whose last object file was removed, left every previously tracked node behind as an orphan. With delete_unused_nodes=True a run that tracks nothing now prunes an existing group; one with no group creates none, and an already-empty group is not re-saved. A context reused without finding a group has nothing to reap. The reap deletes on the tracking context's branch rather than the client's default branch, and both context-manager exits reset the client mode in a finally block so a raising update_group() cannot leave the client tracking. delete_unused() attempts every candidate and returns a ReapResult instead of raising. A failure about the member is recorded against it and the rest are still attempted; the refused members stay in the group so a later run retries them, and update_group() reports them together as TrackingGroupCleanupError, again on every run until whatever blocks the deletion is removed. A failure about the request stops the reap and is re-raised as itself rather than recorded against each remaining member. That includes a timeout and an unreachable server, and also the coded failures the GraphQL path raises as a GraphQLError: an expired or missing token, and a branch that is gone, merged, needs a rebase or is locked by a merge. The group write is attempted first, listing this run's nodes plus the refused members and the ones never reached, so an interrupted reap cannot leave the nodes the run created in no group at all. The member whose delete was interrupted is kept only when the server answered: without an answer the delete may have gone through, and a write naming a node that no longer exists is rejected. When the write fails too, the error that stopped the reap is raised with the write's failure as its cause. A member already removed by another member's cascade is tolerated through the catalogue's NodeNotFoundError, with the legacy message check kept only for servers that report no code. The async and sync contexts share the decisions around the reap and the write, keeping only their own calls. TrackingGroupCleanupError is exported from infrahub_sdk.exceptions, survives pickling, and infrahubctl renders it as a table of each member and the server's reason rather than a traceback. The changed contract of delete_unused() and the new exception type get a changed fragment of their own.
a42e040 to
9d001e5
Compare
There was a problem hiding this comment.
2 issues found across 11 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="infrahub_sdk/query_groups.py">
<violation number="1" location="infrahub_sdk/query_groups.py:66">
P2: `RateLimitError` means the server rejected the delete, but it is not an `ApiError`, so this drops the still-existing member from the group and prevents a later retry. Retain the interrupted member for known rejected requests such as HTTP 429.</violation>
</file>
<file name="changelog/572.changed.md">
<violation number="1" location="changelog/572.changed.md:3">
P2: A group-write failure after a member refusal propagates instead of `TrackingGroupCleanupError`, so this guarantee is unconditional where the implementation is not. Qualify it on the group write succeeding.</violation>
</file>
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
| """ | ||
| if not isinstance(exc, GraphQLError) or not _about_the_member(exc): | ||
| result.error = exc | ||
| first_kept = position if isinstance(exc, ApiError) else position + 1 |
There was a problem hiding this comment.
P2: RateLimitError means the server rejected the delete, but it is not an ApiError, so this drops the still-existing member from the group and prevents a later retry. Retain the interrupted member for known rejected requests such as HTTP 429.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At infrahub_sdk/query_groups.py, line 66:
<comment>`RateLimitError` means the server rejected the delete, but it is not an `ApiError`, so this drops the still-existing member from the group and prevents a later retry. Retain the interrupted member for known rejected requests such as HTTP 429.</comment>
<file context>
@@ -26,6 +41,73 @@ def _node_already_deleted(exc: GraphQLError) -> bool:
+ """
+ if not isinstance(exc, GraphQLError) or not _about_the_member(exc):
+ result.error = exc
+ first_kept = position if isinstance(exc, ApiError) else position + 1
+ result.unattempted = [candidate_id for _, candidate_id in candidates[first_kept:]]
+ return True
</file context>
| @@ -0,0 +1,3 @@ | |||
| `InfrahubGroupContext.delete_unused()` and its sync counterpart no longer raise. They attempt every unused member and return a `ReapResult`, importable from `infrahub_sdk.query_groups`, that lists the members the server refused to delete, the members the cleanup never reached, and the failure that stopped it, if any. A caller that invoked `delete_unused()` directly and relied on it raising must now inspect that result. `update_group()` and the tracking context manager still raise, as described below. | |||
|
|
|||
| Leaving a tracking context whose cleanup was refused now raises `TrackingGroupCleanupError` once every member has been attempted, in place of the first `GraphQLError`. `TrackingGroupCleanupError` is not a `GraphQLError`, so a caller that caught the cleanup failure with `except GraphQLError` must catch it explicitly. A cleanup stopped by a failure that is not about a member, such as a timeout, still raises that failure itself. | |||
There was a problem hiding this comment.
P2: A group-write failure after a member refusal propagates instead of TrackingGroupCleanupError, so this guarantee is unconditional where the implementation is not. Qualify it on the group write succeeding.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At changelog/572.changed.md, line 3:
<comment>A group-write failure after a member refusal propagates instead of `TrackingGroupCleanupError`, so this guarantee is unconditional where the implementation is not. Qualify it on the group write succeeding.</comment>
<file context>
@@ -0,0 +1,3 @@
+`InfrahubGroupContext.delete_unused()` and its sync counterpart no longer raise. They attempt every unused member and return a `ReapResult`, importable from `infrahub_sdk.query_groups`, that lists the members the server refused to delete, the members the cleanup never reached, and the failure that stopped it, if any. A caller that invoked `delete_unused()` directly and relied on it raising must now inspect that result. `update_group()` and the tracking context manager still raise, as described below.
+
+Leaving a tracking context whose cleanup was refused now raises `TrackingGroupCleanupError` once every member has been attempted, in place of the first `GraphQLError`. `TrackingGroupCleanupError` is not a `GraphQLError`, so a caller that caught the cleanup failure with `except GraphQLError` must catch it explicitly. A cleanup stopped by a failure that is not about a member, such as a timeout, still raises that failure itself.
</file context>
| Leaving a tracking context whose cleanup was refused now raises `TrackingGroupCleanupError` once every member has been attempted, in place of the first `GraphQLError`. `TrackingGroupCleanupError` is not a `GraphQLError`, so a caller that caught the cleanup failure with `except GraphQLError` must catch it explicitly. A cleanup stopped by a failure that is not about a member, such as a timeout, still raises that failure itself. | |
| After a successful group write, leaving a tracking context whose cleanup was refused now raises `TrackingGroupCleanupError` once every member has been attempted, in place of the first `GraphQLError`. `TrackingGroupCleanupError` is not a `GraphQLError`, so a caller that caught the cleanup failure with `except GraphQLError` must catch it explicitly. A cleanup stopped by a failure that is not about a member, such as a timeout, still raises that failure itself. |
Why
update_group()returned early whenever a run tracked zero members, so it never diffed the previous membership against the empty set. Any run that saved nothing left every previously tracked node behind as an orphan, still listed in the tracking group. This bites two ways in the field: a generator that legitimately produces nothing (a decommissioning run) never cleans up, and a repository whose last object file is removed leaves its objects stranded.Removing the early return on its own would have turned that silent no-op into a run-killer.
delete_unused()stopped at the first refused delete, and the group was saved before the reap, so a node whose delete was refused had already dropped out of the group and could never be retried.Closes #572. Also fixes #737 (closed as a duplicate, code never changed) and is the SDK half of opsmill/infrahub#10134.
This is rebased onto the error catalogue (#1266) as a single commit and builds on its typed exceptions rather than on message text.
What changed
Behavioral changes:
delete_unused_nodes=True, which generators and repository imports use, a run that tracks nothing now prunes the members of an existing tracking group instead of doing nothing.delete_unused()attempts every unused member instead of stopping at the first refusal. The members the server refused stay in the tracking group so a later run retries them, andupdate_group()reports them together as a newTrackingGroupCleanupError. A refusal that persists is raised again on every run until whatever blocks the deletion is removed; previously the member dropped out of the group after the first failure and stayed behind unnoticed.finallyblock.update_group()raising left the client inTRACKINGmode, silently enrolling every later save into the stale context.What a failed cleanup does
The reap separates failures that are about the member from failures that are about the request, because only the first kind is a fact about that member:
UNDEFINED_ERRORor no code),PERMISSION_DENIED, or a schema that no longer has the member's kind. It is recorded against that member and the remaining candidates are still attempted.After #1266 the GraphQL path raises a coded failure as a
GraphQLErroreven when the code describes the request (TOKEN_EXPIRED,MERGE_IN_PROGRESS, ...), so the class alone cannot separate the two. The reap reads the catalogue code instead, and falls back to the class's declaredCODEfor a lookup miss the SDK raised without a server behind it.Either way the group write is attempted before the failure surfaces, listing the nodes this run created plus the members the reap refused or never reached. An interrupted reap used to abort ahead of the upsert: members already in the group self-heal on the next run's diff, but the nodes the run had just created were in no group at all, and no later run could reach them. When the write fails as well, which it usually does for the same reason, the error that stopped the reap is raised with the write's failure as its cause.
The member whose delete was interrupted is kept in the group only when the server answered (an
ApiError), since a rejected delete removed nothing. When no answer came back, such as on a timeout, the delete may have gone through, and the server rejects a group write that names a node that no longer exists, which would lose this run's membership. So that member is left out, matching what happened before this PR.A member already removed by another member's cascade is tolerated through #1266's
NodeNotFoundError. The message check for "Unable to find the node" is kept only for servers that predate the catalogue and report no code.Also changed
delete_unused()returns aReapResult(refused members, members never attempted, and the error that stopped the reap), importable frominfrahub_sdk.query_groups, instead of raising. Nothing in the SDK or in Infrahub calls it directly, but it is public, so the change has achangedfragment of its own.TrackingGroupCleanupErroris exported frominfrahub_sdk.exceptionsand added to the public-names snapshot. It is not aGraphQLError, so a caller that caught a refused cleanup withexcept GraphQLErrormust catch it explicitly; thechangedfragment says so.TrackingGroupCleanupErrorsurvivespickle, so it survives the serialization a task orchestrator applies to a failed run.infrahubctlrendersTrackingGroupCleanupErroras a table of each member and the server's reason, escaped the way feat: typed exceptions for the server's error catalogue #1266 escapes every other error message, instead of falling through to a traceback.ReapResult.failure(), so the async and sync contexts keep only their own calls.What stayed the same: no change to when tracking is armed, to
delete_unused_nodesdefaults, or to the rollback-on-exception behavior.Deliberately out of scope
delete_unused_nodes=Falsethe existing group is not read, so a run that tracks nothing still leaves it unchanged, while a run that tracks one node replaces the whole membership. Reading the group on that path would add a lookup to every default tracking run. The changelog states the condition.InfrahubBatchalready has the right shape (return_exceptions=True) and is the natural follow-up.How to review
Suggested order:
infrahub_sdk/query_groups.py: the module-level classification helpers, then asyncupdate_group()anddelete_unused(), then confirm the sync twin matches.tests/unit/sdk/test_group_context.py, thentests/integration/test_tracking_zero_members.py.Worth extra scrutiny:
_REQUEST_FAILURE_CODES, the list of catalogue codes treated as about the request. A code missing from it is recorded against each member; a code wrongly in it stops the cleanup early.Also deliberate: raising rather than warning on a refused delete. A decommission that quietly fails to decommission seemed worse than a loud one. The consumer decides what that means in context: opsmill/infrahub#10134 catches it per import phase and continues, because the refused objects stay group members and the next import retries them.
Raised by local cubic reviews and declined:
PERMISSION_DENIEDis counted as about the member. Object permissions are evaluated per kind and branch, so the denial is accurate for each member of that kind, and the members stay in the group either way._REQUEST_FAILURE_CODES: it arrives asRateLimitError, which is not aGraphQLError.add_note, which requires Python 3.11.ReapResultis not exported from the package root, matchingInfrahubGroupContext, which returns it and is not exported either.update_group()still repeat their awaited calls and the four lines handling a failed write, whose bareraisehas to stay inside theexcept.How to test
The unit tests need no Docker daemon. They drive the reap end to end through
httpx_mockwith #1266's recorded catalogue responses, covering for both clients:NODE_NOT_FOUNDduring the reap drops the member without a failureMERGE_IN_PROGRESSduring the reap stops it, keeps every member in the group, and raisesMergeInProgressErrorrather than a cleanup errorRegression guards checked against the pushed code: classifying every
GraphQLErroras a refusal, the rule before #1266 was taken into account, fails the classification cases, the locked-branch test and the failed-write test for both clients.The integration tests need a Docker daemon and were not run locally; CI is their first run.