Skip to content

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

Draft
ogenstad wants to merge 6 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 6 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.

Three further defects in the same code path were found in review and fixed here, all reachable only because a zero-member run now performs a real cleanup:

  • The reap deleted members on the client's default branch while the group lookup and upsert used the tracking context's branch. On a non-default branch that deletes the wrong node or silently no-ops, which matters because repository imports run per Infrahub branch.
  • InfrahubGroupContextSync.get_group() dropped the branch its async twin passes, so the sync client reconciled a same-named group on the default branch.
  • Both context-manager exits now reset the client mode in a finally block. update_group() raising left the client in TRACKING mode, silently enrolling every later save into the stale context.

Deliberately out of scope: delete_unused() still collects only server refusals (GraphQLError). A transport failure mid-reap propagates and that run's group update is lost, which self-heals because the next run diffs against the unchanged group. Widening the catch was tried and reverted: it relabelled outages as per-member "could not be deleted" failures, which is not a fact about any member. A caller that needs to handle transport errors should catch them itself.

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   # 10 passed
uv run pytest tests/integration/test_infrahub_client.py::TestInfrahubNode::test_tracking_mode \
              tests/integration/test_infrahub_client_sync.py::TestInfrahubClientSync::test_tracking_mode

Ten tests, five per client. Reverting only query_groups.py to the unfixed version, keeping everything else, shows which behaviour each one pins:

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 prunes on the tracked branch FAIL FAIL
zero-member run with no group creates nothing PASS PASS

The last row passes in both columns on purpose: it pins the invariant that a tracked run with nothing to do creates no group, so a future change cannot start creating empty ones.

The branch tests are worth a note. Deleting on the default branch does not find a node that only exists on the branch, so the server answers "Unable to find the node", the cleanup treats that as already-deleted-by-cascade and skips it, and the reap reports complete success while deleting nothing. Every other test in the suite runs on main, which is why this was invisible until it was tested directly.

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() collects only server refusals (GraphQLError); a transport failure aborts before the group upsert, and the next run retries against the unchanged group.
  • 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.
  • Zero-member runs still create no group when none exists, and an already-empty group is not re-upserted.

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 aae78f5. 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.51% <75.36%> (+3.32%) ⬆️
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.06% <0.00%> (+1.99%) ⬆️
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 6 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.

All reported issues were addressed across 4 files

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
Comment thread infrahub_sdk/query_groups.py
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: aae78f5
Status: ✅  Deploy successful!
Preview URL: https://b795bc5a.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
The reap running against the client's default branch instead of the tracked
one was invisible to the rest of the suite, because every other test runs on
main. These two exercise a tracked run on a branch for both clients.

Reverting either branch fix makes them fail: deleting on the default branch
does not find a node that only exists on the branch, so the delete is
swallowed as already-deleted and the node survives, and the sync group
lookup misses the branch group entirely and skips the cleanup.
Widening the reap's except clause from GraphQLError to the SDK base Error
went past the problem this branch solves. It also mislabelled outages: a
server that is down or a token that expired would land in the failures map
as a per-member "could not be deleted" entry for every unused member, which
is not a fact about any of them.

Back to GraphQLError, so only a refusal by the server is collected and kept
in the group. Anything else propagates for the caller to handle.

The tradeoff this accepts is that a transport failure mid-reap aborts before
the group upsert, so that run's membership update is lost. The next run
diffs against the unchanged group and retries, so it self-heals.

@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 1 file (changes from recent commits).

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:131">
P1: Custom agent: **Flag AI Slop and Fabricated Changes**

The PR description claims `delete_unused()` collects every SDK `Error`, but both implementations now catch only `GraphQLError`. Transport errors such as `ServerNotReachableError` therefore abort the sweep before remaining members and the group upsert run; catch the SDK `Error` base class if that behavior is intended.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

continue
try:
await self.client.delete(kind=member.typename, id=member.id, branch=self.branch)
except GraphQLError as exc:

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: Custom agent: Flag AI Slop and Fabricated Changes

The PR description claims delete_unused() collects every SDK Error, but both implementations now catch only GraphQLError. Transport errors such as ServerNotReachableError therefore abort the sweep before remaining members and the group upsert run; catch the SDK Error base class if that behavior is intended.

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

<comment>The PR description claims `delete_unused()` collects every SDK `Error`, but both implementations now catch only `GraphQLError`. Transport errors such as `ServerNotReachableError` therefore abort the sweep before remaining members and the group upsert run; catch the SDK `Error` base class if that behavior is intended.</comment>

<file context>
@@ -127,7 +128,7 @@ async def delete_unused(self) -> dict[str, str]:
             try:
                 await self.client.delete(kind=member.typename, id=member.id, branch=self.branch)
-            except Error as exc:
+            except GraphQLError as exc:
                 if exc.message and "Unable to find the node" in exc.message:
                     # The node was already removed by the cascade delete of another node
</file context>

The container fixtures are class-scoped, so every test class boots its own
Infrahub stack. Six classes meant six boots, which pushed
integration-tests-latest-infrahub past its 40 minute timeout: the job passed
in 38m46s before the branch tests were added and was cancelled at 40m28s
after.

The tests were already isolated from each other by distinct tracking params,
which give distinct group names, and distinct object names, so they did not
need separate classes. Two classes now, one per client, same ten tests.
Locally the file drops from 395s to 131s.
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