Skip to content

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

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

ogenstad wants to merge 8 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 refusals 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.

What a failed cleanup does

The reap distinguishes two kinds of failure, because only one of them is a fact about a member:

  • A server refusal (GraphQLError) is attributed to that member, and the remaining candidates are still attempted.
  • Anything else — an unreachable server, an expired token, a timeout — is not about the member whose delete happened to be in flight. It stops the reap and is re-raised as itself, rather than being recorded against every remaining member in turn.

Either way the tracking group is written before the failure surfaces, listing the nodes this run created plus the members the reap refused or never reached. That ordering matters: an interrupted reap used to abort ahead of the upsert, and while members already in the group self-heal on the next run's diff, the nodes the run had just created were in no group at all and no later run could reach them.

Also fixed in the same path

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 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.
  • The "already deleted by cascade" tolerance inspected the whole GraphQLError blob, which includes the mutation text. It now inspects each error in the response, so a cascade-deleted peer reported alongside a genuine refusal is no longer swallowed.
  • Failure reasons carry the server's message rather than the full mutation that triggered it. A decommission blocked on hundreds of members used to produce a message tens of KB long.
  • TrackingGroupCleanupError is reconstructible from its own state, so it survives the serialization a task orchestrator applies to a failed run. It previously raised AttributeError on unpickling.
  • infrahubctl renders the failures as a table instead of falling through to a raw traceback.

Implementation notes:

  • delete_unused() returns a ReapResult (refused members, members never attempted, and the error that stopped it) instead of None. It no longer raises, so a bare dict would have made it too easy for a direct caller to drop a failure silently; the result type names what has to be handled.
  • The group upsert moved to after the reap. This is what makes a refused delete retryable, since membership is replaced rather than merged — and the reap is now non-raising so that move cannot cost the run its membership.
  • The empty-members upsert genuinely clears membership: members=[] reaches the mutation payload, and the server replaces the relationship set.
  • The branch-independent decision logic (member assembly, the previous-vs-current diff, what counts as already-deleted, how a reason is extracted) moved onto InfrahubGroupContextBase, so the async and sync twins share it rather than carrying two copies. That also unified the member ordering the two had quietly drifted on.

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

Known limitation, deliberately not addressed here: the reap issues one sequential delete per unused member, and the zero-member fix makes that path reachable with an entire membership at once. A decommission of several thousand objects is several thousand sequential round trips. InfrahubBatch already has the right shape (return_exceptions=True) and is the natural follow-up; batching it here would have grown this PR past reviewability.

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 review

Suggested order:

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

Worth extra scrutiny: raising versus warning on a refused delete. The code already raised, 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. 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.

Also worth a look: the labelling rule is load-bearing. An earlier revision of this branch widened the reap's except to the SDK Error base and had to be reverted, because it turned one outage into a per-member "could not be deleted" entry for every unused member. The current shape keeps that narrowness without paying the membership loss it previously implied.

How to test

uv run pytest tests/unit/sdk/test_group_context.py
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

The unit file covers the decision logic and the request counts without a Docker daemon, so a regression fails a fast run: a zero-member run with no group and one with an already-empty group each issue a single request and no mutation; an interrupted reap still writes the group carrying the member it never reached before the transport error surfaces; and the pieces that decide what a failure means (a cascade-deleted peer reported alongside a refusal is not "already deleted", a reason never contains the mutation, the exception survives a pickle round trip).

Regression guard shown to bite: dropping the group write from update_group() fails test_failed_reap_still_records_the_membership for both clients. Reverting query_groups.py to the unfixed version fails the zero-member and refused-delete integration tests for both clients.

Integration coverage runs in CI; it needs a Docker daemon and was not run locally.

@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.19048% with 30 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
infrahub_sdk/query_groups.py 85.85% 11 Missing and 3 partials ⚠️
infrahub_sdk/ctl/utils.py 16.66% 9 Missing and 1 partial ⚠️
infrahub_sdk/client.py 25.00% 6 Missing ⚠️
@@                 Coverage Diff                  @@
##           infrahub-develop    #1278      +/-   ##
====================================================
- Coverage             84.88%   81.19%   -3.69%     
====================================================
  Files                   148      149       +1     
  Lines                 13288    14398    +1110     
  Branches               1955     1964       +9     
====================================================
+ Hits                  11279    11690     +411     
- Misses                 1441     2134     +693     
- Partials                568      574       +6     
Flag Coverage Δ
integration-tests ?
python-3.10 61.50% <56.34%> (+3.77%) ⬆️
python-3.11 61.48% <56.34%> (+3.74%) ⬆️
python-3.12 61.48% <56.34%> (+3.75%) ⬆️
python-3.13 61.48% <56.34%> (+3.74%) ⬆️
python-3.14 61.48% <56.34%> (+3.76%) ⬆️
python-filler-3.12 22.10% <20.63%> (-1.67%) ⬇️

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.69% <100.00%> (-2.64%) ⬇️
infrahub_sdk/client.py 81.33% <25.00%> (-2.71%) ⬇️
infrahub_sdk/ctl/utils.py 65.58% <16.66%> (-2.73%) ⬇️
infrahub_sdk/query_groups.py 85.64% <85.85%> (+1.08%) ⬆️

... and 32 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 Outdated
Comment thread infrahub_sdk/query_groups.py Outdated
@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: 1cbc949
Status: ✅  Deploy successful!
Preview URL: https://950af926.infrahub-sdk-python.pages.dev
Branch Preview URL: https://po-tracking-group-zero-membe.infrahub-sdk-python.pages.dev

View logs

@ogenstad
ogenstad force-pushed the po-tracking-group-zero-member-reap branch from ae12445 to 1dc8bad Compare August 31, 2026 12:53

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

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

Re-trigger cubic

Comment thread infrahub_sdk/query_groups.py
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.
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.
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.
An earlier commit on this branch narrowed the reap's except clause back to
GraphQLError, to stop an outage being recorded as a per-member refusal, and
accepted that a transport failure mid-reap aborts before the group upsert.

That loss is not symmetric. Members already in the group do self-heal on the
next run, because the next diff still sees them. The nodes this run just
created are in no group at all, so no later diff can reach them and they
orphan permanently.

Keep the labelling rule and fix the loss. A refusal is a fact about the
member and is still recorded against it, with the remaining candidates still
attempted. Anything else stops the reap and is returned as itself, alongside
the candidates it never reached, so an outage is never blamed on a member.
update_group() then writes the group first, listing this run's nodes plus the
refused and unreached members, and only then re-raises the reap's own error
or TrackingGroupCleanupError.

delete_unused() returns a ReapResult rather than a bare dict, which names the
three outcomes a caller has to handle now that the method no longer raises.

Also from the same review: the already-deleted check inspects each GraphQL
error instead of substring-matching the whole blob, so a cascade-deleted peer
reported alongside a genuine refusal is no longer swallowed; failure reasons
carry the server's message rather than the mutation that caused them;
TrackingGroupCleanupError is reconstructible so it survives serialization by a
task orchestrator; infrahubctl renders the failures as a table instead of a
traceback; and the branch-independent decision logic moves onto the base
class, unifying the member ordering the two clients had drifted on.
The zero-member decision logic and the request counts this branch claims were
only exercised by the docker-backed suite, so a regression in them could not
fail a plain unit run. These pin the local branches directly: a zero-member
run with no group, and one with an already-empty group, each issue a single
request and no mutation, and an interrupted reap still writes the group with
the member it never reached before the transport error surfaces. Dropping the
group write makes that last one fail for both clients.

Also pins what decides the meaning of a failure: an error list carrying a
cascade-deleted peer alongside a refusal is not treated as already-deleted, a
reason never contains the mutation, and TrackingGroupCleanupError survives a
pickle round trip.

The integration tests get the match= the repo's test rules require, and
class-scoped teardown for the branch, nodes and groups they create.
@ogenstad
ogenstad force-pushed the po-tracking-group-zero-member-reap branch from aae78f5 to 1cbc949 Compare September 10, 2026 13:11
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