Skip to content

fix(tracking): reconcile tracking groups on runs that save no nodes - #1278

Draft
ogenstad wants to merge 3 commits into
infrahub-developfrom
po-tracking-group-zero-member-reap
Draft

fix(tracking): reconcile tracking groups on runs that save no nodes#1278
ogenstad wants to merge 3 commits into
infrahub-developfrom
po-tracking-group-zero-member-reap

Conversation

@ogenstad

@ogenstad ogenstad commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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.

While fixing that, a second defect in the same code path had to be fixed first. delete_unused() aborted on the first refused delete, and because the group was saved before the reap, a node whose delete was refused was already out of the group and could never be retried. Removing the early return without fixing that would have turned today's silent no-op into a run-killer: every zero-member run on a group containing an undeletable node would fail and silently skip the remaining members.

Closes #572. Also fixes #737 (closed as a duplicate, code never changed) and is the SDK half of opsmill/infrahub#10134.

What changed

Behavioral changes:

  • A run that tracks nothing now prunes the members of an existing tracking group, instead of doing nothing.
  • A run that tracks nothing and has no existing group still creates no group, and an already-empty group is no longer pointlessly re-upserted.
  • delete_unused() attempts every unused member instead of stopping at the first refusal, and reports the failures together as a new TrackingGroupCleanupError.
  • Members whose deletion was refused stay in the tracking group, so a later run retries them once whatever blocked the delete is gone.
  • InfrahubGroupContextSync.delete_unused() had no error handling at all. It is now at parity with the async variant, including the "already deleted by cascade" tolerance added for bug: SDK Tracking feature errors out when handling parent/component deletion sequence #265.

Implementation notes:

  • delete_unused() returns dict[str, str] (member id to reason) instead of None. Additive for callers that ignore the return value.
  • The group upsert moved to after the reap. This is what makes a refused delete retryable, since membership is replaced rather than merged.
  • The empty-members upsert genuinely clears membership: members=[] reaches the mutation payload, and the server replaces the relationship set.

What stayed the same: no change to when tracking is armed, to delete_unused_nodes defaults, or to the rollback-on-exception behavior.

How to review

Suggested order:

  1. infrahub_sdk/query_groups.py async update_group() for the new control flow, then confirm the sync twin mirrors it exactly.
  2. delete_unused() in both classes.
  3. tests/integration/test_tracking_zero_members.py.

Worth extra scrutiny: raising versus warning on a refused delete. Today the code already raises, just prematurely and after a partial reap, so this keeps raising but only once everything has been attempted and the group has been saved. A silent warning was the alternative, but a decommission that quietly fails to decommission seemed worse than a loud one.

Also deliberate: with delete_unused_nodes=False and zero members, the group is still left stale. Fixing that would cost a lookup on the default path.

How to test

uv run pytest tests/integration/test_tracking_zero_members.py
uv run pytest tests/integration/test_infrahub_client.py::TestInfrahubNode::test_tracking_mode \
              tests/integration/test_infrahub_client_sync.py::TestInfrahubClientSync::test_tracking_mode

All four new tests fail on the unfixed code, verified before the fix was written:

Eight tests, four per client. Reverting only query_groups.py to the unfixed version, keeping the rest, gives 6 failed / 2 passed:

Test async sync
zero-member run prunes previous members FAIL assert 2 == 0 FAIL
refused delete does not abort remaining reaps FAIL FAIL
undeletable member kept and retried later FAIL FAIL
zero-member run with no group creates nothing PASS PASS

The two that pass in both columns are deliberate: they pin the invariant that a tracked run with nothing to do creates no group, so a future change cannot start creating empty ones.

Full integration suite on this branch: 133 passed, 2 xfailed. ruff, mypy, ty and yamllint clean.

Impact & rollout

  • Backward compatibility: behavior change, and destructive on upgrade. Objects and nodes orphaned by earlier versions are deleted on the first tracked run after upgrading. Anyone who worked around this by keeping a placeholder member no longer needs to. delete_unused()'s return type changes from None to dict[str, str], and TrackingGroupCleanupError is new public API, which is why this targets infrahub-develop rather than a patch line.
  • Performance: measured by counting HTTP requests. Steady state with members tracked is unchanged (4 requests); the first run with members is one request cheaper because reordering makes the schema fetch a cache hit. The new cost is a single lookup on a tracked run that has nothing now and nothing before. Repeated zero-member runs settle at 1 request once the group is empty.
  • Config/env changes: none.
  • Deployment notes: the Infrahub-side pointer bump and doc update are a separate PR that depends on this one merging.

Checklist


Summary by cubic

Fixes tracking group reconciliation when a run saves no nodes. Previously zero-member runs were no-ops that left prior members orphaned; now they prune existing members, keep undeletable ones for retry, and report failures together as TrackingGroupCleanupError.

  • Cleanup now honors the tracked branch instead of the client's default branch; the sync client's group lookup passes it too.
  • delete_unused() tolerates any SDK Error, including transport failures like ServerNotReachableError, so a mid-sweep failure no longer skips remaining members.
  • Both tracking context managers reset the client to DEFAULT mode in a finally block, so a raising update_group() can't leave later saves tracked.

Migration

  • delete_unused() returns dict[str, str] instead of None.
  • Catch TrackingGroupCleanupError to handle partial cleanup; failed member reasons are in .failures.
  • First tracked run after upgrade may delete objects and nodes orphaned by earlier versions.

Written for commit 1dc8bad. Summary will update on new commits.

Review in cubic

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 82.60870% with 12 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/query_groups.py 78.57% 6 Missing and 6 partials ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1278      +/-   ##
====================================================
+ Coverage             84.57%   85.37%   +0.80%     
====================================================
  Files                   148      148              
  Lines                 13373    14112     +739     
  Branches               1953     1939      -14     
====================================================
+ Hits                  11310    12048     +738     
- Misses                 1496     1498       +2     
+ Partials                567      566       -1     
Flag Coverage Δ
integration-tests 43.52% <75.36%> (+3.33%) ⬆️
python-3.10 60.04% <0.00%> (+1.97%) ⬆️
python-3.11 60.06% <0.00%> (+1.99%) ⬆️
python-3.12 60.06% <0.00%> (+1.99%) ⬆️
python-3.13 60.04% <0.00%> (+1.97%) ⬆️
python-3.14 60.06% <0.00%> (+1.99%) ⬆️
python-filler-3.12 21.91% <7.24%> (-1.20%) ⬇️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
infrahub_sdk/client.py 79.79% <100.00%> (+0.03%) ⬆️
infrahub_sdk/exceptions.py 90.00% <100.00%> (+0.30%) ⬆️
infrahub_sdk/query_groups.py 87.17% <78.57%> (+2.62%) ⬆️

... and 5 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. 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:207">
P2: When a member deletion is refused, this exception escapes `InfrahubClient.__aexit__`/`__exit__` before either method resets `self.mode` to `DEFAULT`. Reset the mode in a `finally` block so subsequent non-tracking saves do not append to the stale tracking context.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread infrahub_sdk/query_groups.py Outdated
Comment thread infrahub_sdk/query_groups.py

await self.delete_unused()
if failures:
raise TrackingGroupCleanupError(failures=failures)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a member deletion is refused, this exception escapes InfrahubClient.__aexit__/__exit__ before either method resets self.mode to DEFAULT. Reset the mode in a finally block so subsequent non-tracking saves do not append to the stale tracking context.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At infrahub_sdk/query_groups.py, line 207:

<comment>When a member deletion is refused, this exception escapes `InfrahubClient.__aexit__`/`__exit__` before either method resets `self.mode` to `DEFAULT`. Reset the mode in a `finally` block so subsequent non-tracking saves do not append to the stale tracking context.</comment>

<file context>
@@ -147,40 +162,49 @@ async def add_related_groups(self, ids: list[str], update_group_context: bool |
-
-        await self.delete_unused()
+        if failures:
+            raise TrackingGroupCleanupError(failures=failures)
         # TODO : create anoter "read" group. Could be based of the store items
         # Need to filters the store items inherited from CoreGroup to add them as children
</file context>

Comment thread infrahub_sdk/query_groups.py
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 27, 2026

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 1dc8bad
Status: ✅  Deploy successful!
Preview URL: https://80468da8.infrahub-sdk-python.pages.dev
Branch Preview URL: https://po-tracking-group-zero-membe.infrahub-sdk-python.pages.dev

View logs

update_group() returned early whenever the current run tracked no members,
so it never diffed the previous membership against the empty set. A run
that saved nothing left every previously tracked node in place as an
orphan, still listed in the group.

The pruning path now runs when the member list is empty, provided a group
already exists, so a run that tracks nothing still reconciles. A run that
tracks nothing with no existing group continues to create no group, and an
already-empty group is not re-upserted.

delete_unused() no longer aborts on the first refused delete. It attempts
every unused member, returns the ones that failed, and those are reported
together as TrackingGroupCleanupError. Failed members are kept in the group
so a later run retries them, which the previous ordering made impossible:
the group was saved before the reap, so a refused node was already out of
the group and could never be seen again.

InfrahubGroupContextSync.delete_unused() had no error handling at all and
is now at parity with the async variant.
…e sync client

The sync variant of delete_unused() previously had no error handling at
all, so the sync half of the fix was the least covered. Mirrors the four
async tests against InfrahubClientSync.
Review of the reaper surfaced three defects around it, all reachable now
that a zero-member run performs a real cleanup.

The reap deleted members on the client's default branch while the group
lookup and the group upsert both used the tracking context's branch. On a
non-default branch that deletes the wrong node or reports a false failure,
which matters for repository imports since those run per Infrahub branch.

InfrahubGroupContextSync.get_group() dropped the branch that its async twin
passes, so the sync client looked up a same-named group on the default
branch instead of the tracked one.

delete_unused() only tolerated GraphQLError. A transport failure such as
ServerNotReachableError or a rate limit escaped mid-sweep, skipping the
remaining members and aborting before the group upsert. It now records any
SDK Error as a failure, so the sweep completes and the group is still
written with the members that could not be deleted.

Also reset the client mode in a finally block on both context-manager
exits. update_group() raising left the client in TRACKING mode, silently
enrolling every later save into the stale context.
@ogenstad
ogenstad force-pushed the po-tracking-group-zero-member-reap branch from ae12445 to 1dc8bad Compare August 31, 2026 12:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant