Skip to content

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

Draft
ogenstad wants to merge 2 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 2 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 and keep undeletable ones in the group for retry, with failures reported together.

  • New behavior only creates a group when members exist; already-empty groups aren’t re-upserted.

Bug Fixes

  • Zero-member runs prune existing tracking group members; no group is created if none existed.
  • delete_unused() attempts all unused members, tolerates cascade-deleted nodes, and aggregates failures as TrackingGroupCleanupError.
  • Upsert happens after cleanup; members that failed deletion remain in the group for later retries.
  • Sync delete_unused() now matches async error handling.

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 a6763a9. Summary will update on new commits.

Review in cubic

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.
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.66667% with 17 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/query_groups.py 69.09% 10 Missing and 7 partials ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1278      +/-   ##
====================================================
- Coverage             84.16%   84.15%   -0.01%     
====================================================
  Files                   147      147              
  Lines                 13047    13072      +25     
  Branches               1930     1933       +3     
====================================================
+ Hits                  10981    11001      +20     
- Misses                 1503     1507       +4     
- Partials                563      564       +1     
Flag Coverage Δ
integration-tests 39.08% <63.33%> (+0.06%) ⬆️
python-3.10 56.87% <0.00%> (-0.11%) ⬇️
python-3.11 56.87% <0.00%> (-0.13%) ⬇️
python-3.12 56.89% <0.00%> (-0.11%) ⬇️
python-3.13 56.87% <0.00%> (-0.13%) ⬇️
python-3.14 56.87% <0.00%> (-0.13%) ⬇️
python-filler-3.12 23.65% <8.33%> (-0.04%) ⬇️

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

Files with missing lines Coverage Δ
infrahub_sdk/exceptions.py 90.00% <100.00%> (+0.30%) ⬆️
infrahub_sdk/query_groups.py 83.33% <69.09%> (-1.23%) ⬇️
🚀 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.

4 issues 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:129">
P1: When tracking targets a non-default branch, this cleanup call uses `client.default_branch` and can delete the wrong object or report a false failure. Pass `branch=self.branch` to both async and sync member deletions.</violation>

<violation number="2" location="infrahub_sdk/query_groups.py:185">
P2: The upsert (`group.save()`) now runs after `delete_unused()`. If a delete raises any non-`GraphQLError` exception (e.g. `ServerNotReachableError`, `RateLimitError`, a timeout), `update_group()` aborts before the group is saved, so the current run's members are never persisted to the group. Previously the group was saved before deletes, so current members were always recorded even when cleanup failed. Move the group upsert ahead of the reap, or wrap the reap so a hard delete failure still persists/restores the group.</violation>

<violation number="3" 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>

<violation number="4" location="infrahub_sdk/query_groups.py:297">
P1: On a non-default branch, this new zero-member path can miss the branch group or reconcile a same-named default-branch group. Pass the configured branch through the sync group lookup.</violation>
</file>

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

Re-trigger cubic

if member.id not in self.unused_member_ids or not member.typename:
continue
try:
await self.client.delete(kind=member.typename, id=member.id)

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.

P1: When tracking targets a non-default branch, this cleanup call uses client.default_branch and can delete the wrong object or report a false failure. Pass branch=self.branch to both async and sync member deletions.

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 129:

<comment>When tracking targets a non-default branch, this cleanup call uses `client.default_branch` and can delete the wrong object or report a false failure. Pass `branch=self.branch` to both async and sync member deletions.</comment>

<file context>
@@ -108,17 +108,32 @@ async def get_group(self, store_peers: bool = False) -> InfrahubNode | None:
+            if member.id not in self.unused_member_ids or not member.typename:
+                continue
+            try:
+                await self.client.delete(kind=member.typename, id=member.id)
+            except GraphQLError as exc:
+                if exc.message and "Unable to find the node" in exc.message:
</file context>

if not members:
existing_group = None
if self.delete_unused_nodes:
existing_group = self.get_group(store_peers=True)

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.

P1: On a non-default branch, this new zero-member path can miss the branch group or reconcile a same-named default-branch group. Pass the configured branch through the sync group lookup.

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 297:

<comment>On a non-default branch, this new zero-member path can miss the branch group or reconcile a same-named default-branch group. Pass the configured branch through the sync group lookup.</comment>

<file context>
@@ -239,40 +284,49 @@ def add_related_groups(self, ids: list[str], update_group_context: bool | None =
-        if not members:
+        existing_group = None
+        if self.delete_unused_nodes:
+            existing_group = self.get_group(store_peers=True)
+
+        # A run that tracked nothing and has no group to reconcile must not create an empty one.
</file context>


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>

if existing_group:
previous_member_ids: list[str] = existing_group.members.peer_ids # type: ignore[union-attr]
self.unused_member_ids = list(set(previous_member_ids) - set(members))
failures = await self.delete_unused()

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: The upsert (group.save()) now runs after delete_unused(). If a delete raises any non-GraphQLError exception (e.g. ServerNotReachableError, RateLimitError, a timeout), update_group() aborts before the group is saved, so the current run's members are never persisted to the group. Previously the group was saved before deletes, so current members were always recorded even when cleanup failed. Move the group upsert ahead of the reap, or wrap the reap so a hard delete failure still persists/restores the group.

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 185:

<comment>The upsert (`group.save()`) now runs after `delete_unused()`. If a delete raises any non-`GraphQLError` exception (e.g. `ServerNotReachableError`, `RateLimitError`, a timeout), `update_group()` aborts before the group is saved, so the current run's members are never persisted to the group. Previously the group was saved before deletes, so current members were always recorded even when cleanup failed. Move the group upsert ahead of the reap, or wrap the reap so a hard delete failure still persists/restores the group.</comment>

<file context>
@@ -147,40 +162,49 @@ async def add_related_groups(self, ids: list[str], update_group_context: bool |
+        if existing_group:
+            previous_member_ids: list[str] = existing_group.members.peer_ids  # type: ignore[union-attr]
+            self.unused_member_ids = list(set(previous_member_ids) - set(members))
+            failures = await self.delete_unused()
+
+            # An already-empty group that stays empty needs no upsert.
</file context>

…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.
@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: ae12445
Status: ✅  Deploy successful!
Preview URL: https://ee8f083c.infrahub-sdk-python.pages.dev
Branch Preview URL: https://po-tracking-group-zero-membe.infrahub-sdk-python.pages.dev

View logs

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