Skip to content

fix(tracking): delete unused members before the nodes they hold - #1487

Open
FragmentedPacket wants to merge 2 commits into
infrahub-release-1.12from
ai-bug-pipeline-1477-tracking-cleanup-delete-order
Open

FragmentedPacket wants to merge 2 commits into
infrahub-release-1.12from
ai-bug-pipeline-1477-tracking-cleanup-delete-order

Conversation

@FragmentedPacket

@FragmentedPacket FragmentedPacket commented Nov 5, 2026 •

Copy link
Copy Markdown
Contributor

Why

A generator, or any tracking run with delete_unused_nodes=True, creates nodes in dependency order: the node that is held first, then the node that holds it through a mandatory relationship. For example, two IP addresses and then a BGP session whose mandatory src_ip and dst_ip point at them. When a later run stops building that session, the cleanup deleted the unused members in the order the group returns them, which is creation order. The server refused to delete the IP addresses while the session still held them, so the run failed with TrackingGroupCleanupError, and only the next run deleted them.

Goal: one run deletes every unused node, including unused nodes that hold each other through mandatory relationships, without sending a delete that fails only because of the order.

Non-goals:

Closes #1477

What changed

Behavioral changes:

  • The cleanup deletes a node before the nodes it holds through a mandatory relationship, so one run removes nodes that depend on each other.
  • If the order cannot be worked out (the schema or the peers cannot be read, or computing the order raises any other error), nothing is deleted. The failure stops the cleanup like any other request failure: every unused member stays in the group, this run's group is written, and the error is raised. The next run tries again.
  • A member that a node outside the group holds is still refused, kept in the group and reported in TrackingGroupCleanupError.

Implementation notes:

  • DeletionOrder in infrahub_sdk/query_groups.py builds a dependency graph between the candidate kinds from their relationships with optional: false. A peer that is a generic matches every candidate kind that inherits from it. The kinds are sorted with the existing topological_sort().
  • The schema is read with client.schema.all(), which returns the cached branch schema. The cache is already filled by the group lookup, so the order between kinds costs no request. schema.get() is not used per kind, because it downloads the whole branch schema again for every kind that is not in the cache.
  • Kinds that hold nodes of their own kind, or kinds that hold each other, cannot be ordered from the schema alone. For those kinds only, one filters(..., only=[mandatory relationships]) query per kind reads the peers, and the nodes are sorted by them. In every other case no query is sent.
  • A kind that is not in the schema has no known dependencies and goes last. Its delete fails with SchemaNotFoundError and is recorded as a refusal, as before.
  • The delete loop, _record_failure() and ReapResult are unchanged. They work on the ordered list.

What stayed the same:

  • No public API change. delete_unused() and update_group() keep their signatures.
  • When the order is worked out, the same members are deleted as before, only in a different order.

How to review

  1. DeletionOrder and the helpers above it in infrahub_sdk/query_groups.py: the graph, the handling of cycles and the order inside a kind.
  2. _order_for_deletion() and delete_unused() (async and sync): the ordering failure path that keeps every member.
  3. Tests in tests/unit/sdk/test_group_context.py and tests/integration/test_tracking_zero_members.py.

Worth extra scrutiny: whether every relationship with optional: false is one the server enforces when it deletes the peer.

How to test

uv run pytest tests/unit/sdk/test_group_context.py
uv run pytest tests/integration/test_tracking_zero_members.py

The new integration test test_unused_owner_and_animal_are_deleted_in_one_run (async and sync) creates a person and then a cat that holds them through the mandatory owner relationship, then runs an empty tracking session. With the fix, both are deleted in one run. With the ordering switched off, it fails with the error from the issue: "Cannot delete TestingPerson … It is linked to mandatory relationship owner on node TestingCat".

Impact & rollout

  • Backward compatibility: no breaking change.
  • Performance: no extra request in the common case. One peer query per kind that holds nodes of its own kind or of a kind that holds it back, plus one more request per page when that query has more members than the client's page size.
  • Infrahub CI: query_groups.py runs inside Infrahub generators. Running Infrahub's CI against this branch before merging is worth considering.

Checklist

  • Tests added/updated
  • Changelog entry added (changelog/1477.fixed.md)
  • External docs updated (not needed: the tracking guide does not describe the cleanup order or refusals)
  • Internal .md docs updated (none cover this area)

🤖 Generated with Claude Code

View guided diff Turn on auto-fix

The tracking cleanup deleted unused group members in the order the group
returns them, which is creation order. A node created first was deleted
before the unused node that holds it through a mandatory relationship, so
the server refused the delete and the run failed with
TrackingGroupCleanupError.

Order the deletes from the cached schema, and from the members' peers for
kinds that hold nodes of their own kind. If the order cannot be worked out,
nothing is deleted and the failure stops the cleanup, keeping every member
in the group.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@FragmentedPacket
FragmentedPacket requested a review from a team as a code owner November 5, 2026 18:24
@FragmentedPacket FragmentedPacket added the type/bug Something isn't working as expected label Nov 5, 2026
@codecov

codecov Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

@@                    Coverage Diff                    @@
##           infrahub-release-1.12    #1487      +/-   ##
=========================================================
+ Coverage                  87.74%   87.93%   +0.18%     
=========================================================
  Files                        155      157       +2     
  Lines                      15539    15738     +199     
  Branches                    2091     2123      +32     
=========================================================
+ Hits                       13635    13839     +204     
+ Misses                      1344     1339       -5     
  Partials                     560      560              
Flag Coverage Δ
integration-tests 42.52% <51.40%> (+0.04%) ⬆️
python-3.10 62.93% <85.21%> (+0.34%) ⬆️
python-3.11 62.93% <85.21%> (+0.34%) ⬆️
python-3.12 62.91% <85.21%> (+0.33%) ⬆️
python-3.13 62.91% <85.21%> (+0.34%) ⬆️
python-3.14 62.91% <85.21%> (+0.34%) ⬆️
python-3.15 62.93% <85.21%> (+0.35%) ⬆️
python-filler-3.12 23.10% <14.78%> (-0.02%) ⬇️

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

Files with missing lines Coverage Δ
infrahub_sdk/query_groups.py 96.32% <100.00%> (+2.43%) ⬆️

... and 1 file 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.

2 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. 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:282">
P2: `order()` places members whose kinds are missing from the schema last even though their dependency direction is unknown. Treat any unknown kind as an ordering failure before issuing deletes so the reap keeps all candidates.</violation>

<violation number="2" location="infrahub_sdk/query_groups.py:299">
P2: When the peer graph within a kind is cyclic, `_order_block` falls back to group order and deletes anyway. The PR contract says undeterminable ordering should delete nothing and raise. Raise an error here instead, so `delete_unused` records it and stops the reap.</violation>
</file>

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

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment on lines +299 to +300
if ordered_ids is None:
return members

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 the peer graph within a kind is cyclic, _order_block falls back to group order and deletes anyway. The PR contract says undeterminable ordering should delete nothing and raise. Raise an error here instead, so delete_unused records it and stops the reap.

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

<comment>When the peer graph within a kind is cyclic, `_order_block` falls back to group order and deletes anyway. The PR contract says undeterminable ordering should delete nothing and raise. Raise an error here instead, so `delete_unused` records it and stops the reap.</comment>

<file context>
@@ -120,6 +121,207 @@ def failure(self) -> Exception | None:
+        # A cycle of mandatory relationships between nodes cannot be stored, but keep the
+        # group order rather than fail if the peers read back describe one.
+        ordered_ids = _dependents_first(dependencies=dependencies, order=member_ids)
+        if ordered_ids is None:
+            return members
+        kind_of = {member_id: kind for kind, member_id in members}
</file context>
Suggested change
if ordered_ids is None:
return members
if ordered_ids is None:
raise DependencyCycleExistsError("Mandatory peers of unused members form a cycle; cannot order deletion")

ordered: list[tuple[str, str]] = []
for key in block_order:
ordered.extend(self._order_block(block=blocks[key], member_peers=member_peers))
ordered.extend(candidate for candidate in self.candidates if candidate[0] in self.unknown_kinds)

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: order() places members whose kinds are missing from the schema last even though their dependency direction is unknown. Treat any unknown kind as an ordering failure before issuing deletes so the reap keeps all candidates.

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

<comment>`order()` places members whose kinds are missing from the schema last even though their dependency direction is unknown. Treat any unknown kind as an ordering failure before issuing deletes so the reap keeps all candidates.</comment>

<file context>
@@ -120,6 +121,207 @@ def failure(self) -> Exception | None:
+        ordered: list[tuple[str, str]] = []
+        for key in block_order:
+            ordered.extend(self._order_block(block=blocks[key], member_peers=member_peers))
+        ordered.extend(candidate for candidate in self.candidates if candidate[0] in self.unknown_kinds)
+        return ordered
+
</file context>
Suggested change
ordered.extend(candidate for candidate in self.candidates if candidate[0] in self.unknown_kinds)
if self.unknown_kinds:
raise ValueError(f"Unable to order members with unknown kinds: {sorted(self.unknown_kinds)}")

Add tests for a mandatory peer that is not being deleted, peers that
form a cycle, and reading peers of both cardinalities. Remove a check in
DeletionOrder.peer_lookups that could never be false.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Nov 5, 2026 •

Copy link
Copy Markdown

Deploying infrahub-sdk-python with  Cloudflare Pages  Cloudflare Pages

Latest commit: 694c687
Status: ✅  Deploy successful!
Preview URL: https://60e13a81.infrahub-sdk-python.pages.dev
Branch Preview URL: https://ai-bug-pipeline-1477-trackin.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

type/bug Something isn't working as expected

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant