fix(backend): delete branch data in bounded batches - #10132
Conversation
Deleting a large branch ran as a single Cypher statement whose peak transaction memory scaled with the size of the branch: it collected the element id of every vertex touched by a deleted edge into two lists and concatenated them. On a big enough branch that exceeded dbms.memory.transaction.total.max, and because the inner writes committed in batches the failure left the branch stranded in DELETING -- invisible in the branch list -- with most of its data still in the graph. Replace it with a set of bounded queries driven from Python by a new BranchDeleter component: the agnostic peers of branch-only nodes first, then one batch of edges per relationship type until none are left. Peak transaction memory is now a function of the batch size rather than the branch, and naming the relationship type lets the branch range index serve the match instead of scanning every edge in the database. Each batch also deletes the vertices its edge deletions left bare. That is only sound because every branch edge is removed by the batch's DELETE and both endpoints are re-examined afterwards, so the batch that removes a vertex's last edge is the one that sees it at degree zero. A DETACH DELETE would break it by removing edges that never reach a batch of their own, stranding the vertices on their far side. Branch.delete now raises instead of silently dropping only the Branch vertex; callers use BranchDeleter. On a branch of 1,074,217 edges this deletes the same 416,021 vertices as before, in 27.8s rather than 87.9s, and completes at a 512 MiB transaction limit where the previous implementation ran out of memory. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
5 issues found across 12 files
Confidence score: 2/5
- In
backend/infrahub/core/branch/deleter.py, a failed deletion batch can leave a branch stuck inDELETINGand invisible to retry selection, so cleanup may never resume and branches can remain in a broken limbo state — make the retry path explicitly re-queue/resumeDELETINGbranches after partial failure. - In
backend/infrahub/core/query/branch.py, the agnostic cleanup transaction cap is applied per node, not per deleted relationship/attribute, so high-degree nodes can still blow past the intended transaction bound and cause unstable or expensive delete runs — batch directly on matched relationships/attributes instead of node count. - In
backend/infrahub/core/query/branch.py, the branch-deletion predicate can treat a historical activeIS_PART_OFedge as proof that an agnostic peer is still referenced, which can leak peers that should be removed and leave stale graph data — tighten the predicate to evaluate branch/time-correct active references only. - In
backend/infrahub/core/query/branch.py, the_delete_edgesloop relies on nullablerelationships_deletedstats for termination, so missing counters can prematurely stop or mis-handle progress reporting in batched deletes — harden the stop condition against null stats and add a regression test around zero/null counter batches.
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="backend/infrahub/core/branch/models.py">
<violation number="1" location="backend/infrahub/core/branch/models.py:326">
P2: Custom agent: **Flag AI Slop and Fabricated Changes**
The changed behavior of `Branch.delete` (now unconditionally raising `NotImplementedError`) is not covered by tests, and a regression-style assertion would be practical. Consider adding a test that verifies `Branch.delete` raises `NotImplementedError` and, if callers previously relied on the `ValidationError` for default/global branches, confirm those callers were migrated to `BranchDeleter`.</violation>
</file>
<file name="backend/infrahub/core/branch/deleter.py">
<violation number="1" location="backend/infrahub/core/branch/deleter.py:47">
P1: A failed batch still leaves the branch undiscoverable to the deletion workflow because this status is saved before cleanup and the retry reload filters `DELETING` branches. The retry path should explicitly resume `DELETING` branches (or otherwise make cleanup idempotently reachable) so the failure mode described in the commit does not strand branches again.</violation>
</file>
<file name="backend/infrahub/core/query/branch.py">
<violation number="1" location="backend/infrahub/core/query/branch.py:47">
P2: A high-degree node can still exceed the intended transaction bound during agnostic cleanup: `IN TRANSACTIONS OF batch_size ROWS` limits nodes, not the relationships or attributes deleted per node. Batch the matched `rel`/`attr` vertices themselves so peer fan-out is bounded too.</violation>
<violation number="2" location="backend/infrahub/core/query/branch.py:77">
P2: Branch deletion can leak agnostic peers when the node has an active historical `IS_PART_OF` edge plus a later deleted edge on another branch, because this predicate treats the historical active edge as proof that the node still exists there. Resolve the latest edge per branch/time and require `status = "active"` with `to IS NULL` before preserving the peer.</violation>
<violation number="3" location="backend/infrahub/core/query/branch.py:140">
P2: The batched delete loop in `BranchDeleter._delete_edges` uses `deleted_edge_count()` as its only stop condition, and that method sums the nullable `query.stats.get_counter("relationships_deleted")`. If a batch's stats payload ever lacks `relationships-deleted`, `QueryStat.relationships_deleted` stays `None` and `get_counter` raises a `TypeError` mid-delete, which would abort branch deletion rather than finishing the remaining edges. Consider guarding against a `None` counter (e.g. treating it as 0) so the termination signal is always an integer, keeping the batching loop robust.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| if branch.is_global: | ||
| raise ValidationError(f"Unable to delete {branch.name} this is an internal branch.") | ||
|
|
||
| branch.status = BranchStatus.DELETING |
There was a problem hiding this comment.
P1: A failed batch still leaves the branch undiscoverable to the deletion workflow because this status is saved before cleanup and the retry reload filters DELETING branches. The retry path should explicitly resume DELETING branches (or otherwise make cleanup idempotently reachable) so the failure mode described in the commit does not strand branches again.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/branch/deleter.py, line 47:
<comment>A failed batch still leaves the branch undiscoverable to the deletion workflow because this status is saved before cleanup and the retry reload filters `DELETING` branches. The retry path should explicitly resume `DELETING` branches (or otherwise make cleanup idempotently reachable) so the failure mode described in the commit does not strand branches again.</comment>
<file context>
@@ -0,0 +1,100 @@
+ if branch.is_global:
+ raise ValidationError(f"Unable to delete {branch.name} this is an internal branch.")
+
+ branch.status = BranchStatus.DELETING
+ await branch.save(db=self.db)
+
</file context>
There was a problem hiding this comment.
Both lookups now pass ignore_deleting=False — the flow (core/branch/tasks.py) and the BranchDelete mutation (graphql/mutations/branch.py). Without the mutation change a user still could not retry, only an automated re-submission of the workflow.
Test added: test_branch_delete_retries_a_branch_left_deleting. It fails without the change with exactly BranchNotFoundError: Branch: stuck-deleting-branch not found.
Two consequences we accepted deliberately and recorded in the commit message: the mutation will now accept a delete for a branch whose delete is still running (each query is idempotent, but nothing serializes the two runs), and a retry re-emits BranchDeletedEvent. Making the surrounding flow steps idempotent is left for separate work.
| WHERE e.branch = $branch_name | ||
| AND NOT EXISTS { | ||
| MATCH (n)-[ipo:IS_PART_OF {status: "active"}]->(:Root) | ||
| WHERE ipo.branch <> $branch_name |
There was a problem hiding this comment.
P2: Branch deletion can leak agnostic peers when the node has an active historical IS_PART_OF edge plus a later deleted edge on another branch, because this predicate treats the historical active edge as proof that the node still exists there. Resolve the latest edge per branch/time and require status = "active" with to IS NULL before preserving the peer.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/query/branch.py, line 77:
<comment>Branch deletion can leak agnostic peers when the node has an active historical `IS_PART_OF` edge plus a later deleted edge on another branch, because this predicate treats the historical active edge as proof that the node still exists there. Resolve the latest edge per branch/time and require `status = "active"` with `to IS NULL` before preserving the peer.</comment>
<file context>
@@ -10,83 +10,137 @@
+WHERE e.branch = $branch_name
+AND NOT EXISTS {
+ MATCH (n)-[ipo:IS_PART_OF {status: "active"}]->(:Root)
+ WHERE ipo.branch <> $branch_name
+}
+CALL (n) {
</file context>
There was a problem hiding this comment.
The observation is accurate — the predicate does accept status: "active" without checking to IS NULL — but I don't think the conclusion holds, and the suggested fix would lose data. Not changing it.
Two things first: this predicate is carried over character for character from before this PR (only the surrounding OPTIONAL MATCH … LIMIT 1 / RETURN ipo IS NOT NULL was rewritten as NOT EXISTS { }), so nothing here is new behaviour.
Second, NodeDeleteQuery (core/query/node.py:644-651) only closes the active edge when that edge is on the branch doing the deleting:
CREATE (n)-[delete_edge:IS_PART_OF { branch: $branch, status: "deleted", from: $at }]->(root)
WITH r
WHERE r.branch = $branch // ← only then
SET r.to = $atSo reaching the case you describe takes: create n on branch X → merge X to main (n gains an active main edge) → delete n on main (same-branch, so main's active edge gets to set) → delete branch X.
At that point n is not garbage. It keeps its main edges, so it is never orphaned, and main's history still contains it — a query against main at a time before the deletion must render n, which needs those agnostic attributes to still exist. Requiring to IS NULL would delete the agnostic attributes of every node that was ever deleted on another branch and break historical (--at) reads of it.
The other reachable cases already behave correctly: a node created and deleted only on X has no other-branch active edge, so its peers are cleaned up; a node created on main and deleted on X never gets an active edge on X, so the outer MATCH does not select it at all.
| OPTIONAL MATCH (n)-[:HAS_ATTRIBUTE {branch: $global_branch_name}]-(attr:Attribute) | ||
| DETACH DELETE attr | ||
| } IN TRANSACTIONS OF 500 ROWS | ||
| } IN TRANSACTIONS OF %(batch_size)s ROWS |
There was a problem hiding this comment.
P2: A high-degree node can still exceed the intended transaction bound during agnostic cleanup: IN TRANSACTIONS OF batch_size ROWS limits nodes, not the relationships or attributes deleted per node. Batch the matched rel/attr vertices themselves so peer fan-out is bounded too.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/query/branch.py, line 47:
<comment>A high-degree node can still exceed the intended transaction bound during agnostic cleanup: `IN TRANSACTIONS OF batch_size ROWS` limits nodes, not the relationships or attributes deleted per node. Batch the matched `rel`/`attr` vertices themselves so peer fan-out is bounded too.</comment>
<file context>
@@ -10,83 +10,137 @@
- OPTIONAL MATCH (n)-[:HAS_ATTRIBUTE {branch: $global_branch_name}]-(attr:Attribute)
- DETACH DELETE attr
-} IN TRANSACTIONS OF 500 ROWS
+} IN TRANSACTIONS OF %(batch_size)s ROWS
+ """ % {"batch_size": self.batch_size}
+ self.params["branch_name"] = self.branch_name
</file context>
There was a problem hiding this comment.
Valid. Fixed in 7175415.
This PR had changed that batch from a hardcoded 500 to query_size_limit (5000), so the transactions were 10× larger than before, in a change whose whole purpose is bounding transaction memory. That was incidental rather than deliberate: query_size_limit was simply the value already in hand.
Reverted to 500 via a named AGNOSTIC_PEER_BATCH_SIZE constant, with the distinction recorded where it is easy to miss — phase 1 batches Nodes with unbounded peer fan-out, phase 2 batches edges where the work per row is fixed, so they must not share a number.
| self.params["batch_size"] = self.batch_size | ||
| self.add_to_query(query) | ||
|
|
||
| def deleted_edge_count(self) -> int: |
There was a problem hiding this comment.
P2: The batched delete loop in BranchDeleter._delete_edges uses deleted_edge_count() as its only stop condition, and that method sums the nullable query.stats.get_counter("relationships_deleted"). If a batch's stats payload ever lacks relationships-deleted, QueryStat.relationships_deleted stays None and get_counter raises a TypeError mid-delete, which would abort branch deletion rather than finishing the remaining edges. Consider guarding against a None counter (e.g. treating it as 0) so the termination signal is always an integer, keeping the batching loop robust.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/query/branch.py, line 140:
<comment>The batched delete loop in `BranchDeleter._delete_edges` uses `deleted_edge_count()` as its only stop condition, and that method sums the nullable `query.stats.get_counter("relationships_deleted")`. If a batch's stats payload ever lacks `relationships-deleted`, `QueryStat.relationships_deleted` stays `None` and `get_counter` raises a `TypeError` mid-delete, which would abort branch deletion rather than finishing the remaining edges. Consider guarding against a `None` counter (e.g. treating it as 0) so the termination signal is always an integer, keeping the batching loop robust.</comment>
<file context>
@@ -10,83 +10,137 @@
+ self.params["batch_size"] = self.batch_size
+ self.add_to_query(query)
+
+ def deleted_edge_count(self) -> int:
+ return self.stats.get_counter("relationships_deleted")
+
</file context>
There was a problem hiding this comment.
Not taking this one — the path is unreachable for this query, tested rather than reasoned about.
A batch that deletes nothing produces no stats entry at all, so get_counter sums an empty list and returns 0:
stats entries: []
deleted_edge_count(): 0
That is DeleteBranchEdgesQuery run against a branch name matching nothing, i.e. exactly the terminating batch of every edge type. Query.execute only appends a QueryStat when "stats" in metadata, and Neo4j omits the key entirely when a query makes no updates.
For relationships_deleted to be None you would need a stats payload that is present but missing relationships-deleted. This query cannot produce that: if any row matched, DELETE r removed at least one relationship, so the counter is present; if no row matched there are no updates and no payload. There is no third state.
Guarding QueryStats.get_counter would be a change to shared code (three other call sites) for a path this query cannot reach, so it seems better as its own hardening change if we want it
| NotImplementedError: Always. | ||
|
|
||
| """ | ||
| raise NotImplementedError("Unable to delete a Branch directly, use BranchDeleter instead.") |
There was a problem hiding this comment.
P2: Custom agent: Flag AI Slop and Fabricated Changes
The changed behavior of Branch.delete (now unconditionally raising NotImplementedError) is not covered by tests, and a regression-style assertion would be practical. Consider adding a test that verifies Branch.delete raises NotImplementedError and, if callers previously relied on the ValidationError for default/global branches, confirm those callers were migrated to BranchDeleter.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/branch/models.py, line 326:
<comment>The changed behavior of `Branch.delete` (now unconditionally raising `NotImplementedError`) is not covered by tests, and a regression-style assertion would be practical. Consider adding a test that verifies `Branch.delete` raises `NotImplementedError` and, if callers previously relied on the `ValidationError` for default/global branches, confirm those callers were migrated to `BranchDeleter`.</comment>
<file context>
@@ -315,17 +314,16 @@ async def create(self, db: InfrahubDatabase, user_id: str = SYSTEM_USER_ID) -> b
+ NotImplementedError: Always.
+
+ """
+ raise NotImplementedError("Unable to delete a Branch directly, use BranchDeleter instead.")
def get_query_filter_relationships(
</file context>
There was a problem hiding this comment.
Fair — both halves addressed in 7175415, in backend/tests/component/core/test_branch.py.
test_branch_delete_method_is_refused asserts the NotImplementedError with an anchored match, and additionally that the branch is left untouched (still resolvable, still OPEN) rather than half-deleted.
On your second point: the ValidationError guards for the default and global branches were not dropped, they moved onto BranchDeleter.delete(). test_branch_deleter_refuses_default_and_global_branches covers both and asserts the default branch is unaltered, since those guards fire before any write.
Context on why Branch.delete raises rather than being deleted outright: Branch inherits StandardNode.delete, so removing the override would make branch.delete(db=db) silently drop the Branch vertex and orphan every edge and vertex on the branch — a quieter and worse failure than the one this PR fixes.
A branch delete that ran out of transaction memory committed part of its work before failing, leaving the branch with the DELETING status and the rest of its data in the graph. That state was unreachable: the branch is filtered out of the branch list, and Branch.get_by_name hides it by default, so the delete could not be retried and the space could not be reclaimed. Add graph migration 075, which finds every branch still in DELETING and runs it through BranchDeleter, then removes the branch node. It reuses the normal delete path rather than reimplementing it, loading each branch via the ignore_deleting escape hatch. Finding them needs a dedicated query because the shared branch list query filters DELETING out unconditionally. BranchDeleter now returns the number of edges it removed so the migration can report progress on the migration console. `infrahub db migrate` raises the infrahub log level to WARNING, which hides the deleter's own logging, and deleting a large branch takes long enough that silence looks like a hung upgrade. The migration is a no-op where no branch is in DELETING, and safe to re-run: an interrupted delete resumes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A delete that failed part way through set the DELETING status before doing any cleanup, and both the BranchDelete mutation and the branch-delete flow looked the branch up with the default lookup, which hides that status. The retry reported the branch as missing, so the only way to reclaim the data was the upgrade migration -- which runs once, leaving any later failure stranded for good. Both lookups now pass ignore_deleting=False. Two consequences worth knowing: the mutation will accept a delete for a branch whose delete is still running, and nothing serialises the two runs (each query involved is idempotent); and a retry re-emits BranchDeletedEvent. Making the surrounding flow steps idempotent is left for later. Cap the agnostic cleanup at 500 rows rather than query_size_limit. Those batches count Nodes, and each one can drag an unbounded number of peer vertices into the transaction with it, unlike the edge batches where one row is one edge. 500 is what this phase used before the batching work, and raising it to 5000 was incidental rather than deliberate. Drop the edge accounting comment claiming the vertex cleanup can remove extra edges. That was true while the cleanup used DETACH DELETE; it now only deletes vertices that are already bare. Add the missing tests: Branch.delete refusing, the default/global guards that moved onto BranchDeleter, and retrying a branch left in DELETING. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
2 issues found across 9 files (changes from recent commits).
Confidence score: 2/5
- In
backend/infrahub/graphql/mutations/branch.py(BranchDelete), concurrent requests can both proceed when a branch is alreadyDELETING, which risks duplicate cleanup, duplicate post-delete events, and repeated Git operations—add a branch-scoped lock or atomic state transition so only one delete workflow can run. - In
backend/tests/component/graphql/mutations/test_branch.py, the retry test only checksok: Truewhileworkflow.execute_workflowis mocked, so it can miss whether a strandedDELETINGbranch is actually reclaimed—extend the test to assert state recovery/idempotent cleanup behavior, not just mutation success.
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="backend/infrahub/graphql/mutations/branch.py">
<violation number="1" location="backend/infrahub/graphql/mutations/branch.py:152">
P1: Concurrent `BranchDelete` requests can now both run deletion workflows for a branch already in `DELETING`, so branch cleanup and post-delete events/Git cleanup may be processed twice. A branch-scoped lock or atomic active-versus-stranded deletion claim would allow retries without admitting an active duplicate.</violation>
</file>
<file name="backend/tests/component/graphql/mutations/test_branch.py">
<violation number="1" location="backend/tests/component/graphql/mutations/test_branch.py:573">
P3: The retry test mocks `workflow.execute_workflow` and only asserts the mutation returns `ok: True`, so it never verifies the stranded DELETING branch is actually reclaimed. It validates the mutation's `ignore_deleting=False` resolution (which would fail this test if regressed), but a regression in the real retry path (`delete_branch` → `BranchDeleter.delete()` on a DELETING branch) would pass silently. Consider asserting the branch remains resolvable with `ignore_deleting=False`/is removed after retry, or adding a flow-level test that exercises the actual deletion rather than mocking it.</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Re-trigger cubic
| graphql_context: GraphqlContext = info.context | ||
| obj = await Branch.get_by_name(db=graphql_context.db, name=str(data.name)) | ||
| # ignore_deleting=False so a delete that failed part way through can be retried: the first | ||
| obj = await Branch.get_by_name(db=graphql_context.db, name=str(data.name), ignore_deleting=False) |
There was a problem hiding this comment.
P1: Concurrent BranchDelete requests can now both run deletion workflows for a branch already in DELETING, so branch cleanup and post-delete events/Git cleanup may be processed twice. A branch-scoped lock or atomic active-versus-stranded deletion claim would allow retries without admitting an active duplicate.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/graphql/mutations/branch.py, line 152:
<comment>Concurrent `BranchDelete` requests can now both run deletion workflows for a branch already in `DELETING`, so branch cleanup and post-delete events/Git cleanup may be processed twice. A branch-scoped lock or atomic active-versus-stranded deletion claim would allow retries without admitting an active duplicate.</comment>
<file context>
@@ -148,7 +148,8 @@ async def mutate(
graphql_context: GraphqlContext = info.context
- obj = await Branch.get_by_name(db=graphql_context.db, name=str(data.name))
+ # ignore_deleting=False so a delete that failed part way through can be retried: the first
+ obj = await Branch.get_by_name(db=graphql_context.db, name=str(data.name), ignore_deleting=False)
await apply_external_context(graphql_context=graphql_context, context_input=context)
</file context>
There was a problem hiding this comment.
Accurate, but this is a consequence we accepted deliberately rather than an oversight — it is called out in the commit message for 7175415f5 and in the PR description, both of which note that the mutation will now accept a delete for a branch whose delete is still running, that nothing serialises the two runs, and that a retry re-emits BranchDeletedEvent.
The alternative was leaving a branch stranded permanently: before this change the DELETING status hid the branch from the mutation's own lookup, so a failed delete could not be retried at all, and the upgrade migration — which runs once — was the only route back. That was judged the worse failure.
| branch.status = BranchStatus.DELETING | ||
| await branch.save(db=db) | ||
|
|
||
| with patch.object(local_services.workflow, "execute_workflow", new=AsyncMock(return_value=None)): |
There was a problem hiding this comment.
P3: The retry test mocks workflow.execute_workflow and only asserts the mutation returns ok: True, so it never verifies the stranded DELETING branch is actually reclaimed. It validates the mutation's ignore_deleting=False resolution (which would fail this test if regressed), but a regression in the real retry path (delete_branch → BranchDeleter.delete() on a DELETING branch) would pass silently. Consider asserting the branch remains resolvable with ignore_deleting=False/is removed after retry, or adding a flow-level test that exercises the actual deletion rather than mocking it.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/tests/component/graphql/mutations/test_branch.py, line 573:
<comment>The retry test mocks `workflow.execute_workflow` and only asserts the mutation returns `ok: True`, so it never verifies the stranded DELETING branch is actually reclaimed. It validates the mutation's `ignore_deleting=False` resolution (which would fail this test if regressed), but a regression in the real retry path (`delete_branch` → `BranchDeleter.delete()` on a DELETING branch) would pass silently. Consider asserting the branch remains resolvable with `ignore_deleting=False`/is removed after retry, or adding a flow-level test that exercises the actual deletion rather than mocking it.</comment>
<file context>
@@ -554,6 +554,36 @@ async def test_branch_delete_own_branch_succeeds(
+ branch.status = BranchStatus.DELETING
+ await branch.save(db=db)
+
+ with patch.object(local_services.workflow, "execute_workflow", new=AsyncMock(return_value=None)):
+ delete_result = await graphql_mutation(
+ query='mutation { BranchDelete(data: { name: "stuck-deleting-branch" }) { ok } }',
</file context>
There was a problem hiding this comment.
Fair, and left as is — with the gap acknowledged rather than papered over.
What the test does guard is the thing the change actually altered: the mutation's branch lookup. It fails without ignore_deleting=False, with BranchNotFoundError: Branch: stuck-deleting-branch not found., and that failure occurs at the lookup, before any of the permission or workflow logic is reached.
What it does not cover, as you say, is delete_branch → BranchDeleter.delete() against a branch already in DELETING. That path is exercised indirectly by the migration tests, which delete a DELETING branch through BranchDeleter and assert the edge counts and branch node afterwards — but not through the flow. A flow-level test would be better and is not in this PR.
Note the mock here is the file's existing convention for branch-delete mutation tests (test_branch_delete_own_branch_succeeds does the same), not a new choice. The repo's testing rules discourage unittest.mock, so a flow-level test would want a proper workflow adapter rather than another patch.object.
The test created the branch with `create_branch`, which leaves created_by as the system user, so the BranchDelete mutation took its permission branch and the assertion depended on how a super-admin grant resolves against a specific DELETE_BRANCH check. That resolved differently in CI and the test failed with PermissionDeniedError. Use the existing first_account / session_first_account fixtures and make that account the branch owner, so created_by matches the requesting account, the permission check is skipped, and the result turns on the DELETING status alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Address review findings on migration 075 and the deleter's accounting. The migration's branch lookup suppressed the generated pagination. A read query with no limit of its own is executed page by page, so with the SKIP/LIMIT suppressed every page re-read the whole set and the paging never reached a short page: with at least query_size_limit stranded branches the upgrade would never finish. Pagination is enabled again and the lookup orders by branch name, which is unique, so the pages are disjoint. Each branch is now deleted in its own try. A failure is reported as "branch '<name>': <error>" and the loop continues, so an operator gets the names of everything that still needs a re-run instead of the first exception and an unknown remainder. Failing to list the branches at all still aborts, since there is then nothing to iterate. Cap the agnostic cleanup at min(batch_size, 500) rather than a fixed 500, so lowering the configured batch size to fit a constrained database is not answered with a larger batch than was asked for. Count the agnostic cleanup's edges towards the total the deleter reports. It detaches peer vertices, so leaving it out made the migration's per-branch progress undercount for any branch with agnostic data. The 075 test asserted the stalled branch's node was unreachable from main, which it always was -- it only ever existed on that branch, so the assertion held whether or not the migration ran. It now reads the node on its own branch, before and after. A third case covers one branch of three failing, using a FailingBranchDeleter that delegates to the real deleter for the others so the test proves they were reclaimed rather than merely attempted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
0 issues found across 4 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 5 unresolved issues from previous reviews.
Re-trigger cubic
ogenstad
left a comment
There was a problem hiding this comment.
I added a small comment regarding a ruff ignore line that would be good to add.
I'm not so fond around the requirement to have a NotImplemented exception for Branch.delete(), it seems like a Liskov violation for StandardNode.delete(). I'm not sure we need to deal with it now but it might be confusing moving forward if we start to see more types of StandardNode objects. We can go with this for now and later evaluate when we have other types. I'm also not sure if any special handling would be required for other types. It might be that we should remove StandardNode.delete() and have something else for other nodes as well, or if we can have some common API.
| try: | ||
| branch = await Branch.get_by_name(db=db, name=branch_name, ignore_deleting=False) | ||
| edges_removed = await deleter.delete(branch=branch) | ||
| except Exception as exc: |
There was a problem hiding this comment.
Here I think it could be good with an inline noqa. For reference check #10002 and how other migrations were changed. Otherwise we'll run into problems later. Ideally we should know all of the errors that the migrations can raise and catch those instead of a bare Exception but I think that's something that will come later.
There was a problem hiding this comment.
ruff actually won't let me
RUF100 [*] Unused `noqa` directive (non-enabled: `BLE001`)
… branch BranchDeleter.delete returned the number of edges it removed, which only the upgrade migration used, for its progress output. It said nothing about whether this attempt was the one that removed the branch -- so now that a delete can be retried, two attempts on the same branch could both go on to cancel the proposed changes, emit BranchDeletedEvent and delete the Git branch. Return a BranchDeleteResult carrying both branch_deleted and edges_removed. Removing the vertex is itself the claim: two attempts are serialised on it, so exactly one reports nodes_deleted, with no window of the kind a read followed by a delete would leave. The branch-delete flow returns early when it did not make the claim, and the migration reports whether the branch was still there. Skip the DELETING status write when the status is already set. It is a wasted query for a branch whose earlier delete failed part way through, and it fails outright if the branch has meanwhile been removed -- which made delete() unsafe to call twice at all, whatever it returned. A narrower window remains: an attempt that has not yet written the status, and whose write lands after another attempt removed the vertex, still raises rather than reporting false. It fails the run instead of double-processing, and closing it properly wants a branch-scoped lock. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
All reported issues were addressed across 5 files (changes from recent commits).
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
There was a problem hiding this comment.
1 issue found across 14 files (changes from recent commits).
Confidence score: 2/5
- In
backend/infrahub/core/branch/delete_coordinator.py, the delete race handling can let a losing concurrent delete assume post-delete work is already done, so proposed-change cancellation andBranchDeletedEventmay be skipped entirely if the winner crashes at the wrong point. That can leave orphaned proposed changes and downstream consumers unaware of deletion—make post-delete steps idempotent and guaranteed to run/retry after branch removal (e.g., persisted completion marker or retryable outbox-driven follow-up).
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="backend/infrahub/core/branch/delete_coordinator.py">
<violation number="1" location="backend/infrahub/core/branch/delete_coordinator.py:64">
P1: A concurrent losing delete can permanently skip proposed-change cancellation and `BranchDeletedEvent`: if the winner exits after removing the branch but before post-delete work, the loser treats that work as complete even though no durable completion is recorded. Running these idempotent post-delete actions for every attempt, or recording them in a durable retryable job, would avoid this gap.
(Based on your team's feedback about retryable DELETING branch deletes.) .</violation>
</file>
Shadow auto-approve: would not auto-approve because issues were found.
Tip: Review your code locally with the cubic CLI to iterate faster.
Re-trigger cubic
|
|
||
| result = await self.data_deleter.delete(branch=branch) | ||
|
|
||
| if result.branch_deleted: |
There was a problem hiding this comment.
P1: A concurrent losing delete can permanently skip proposed-change cancellation and BranchDeletedEvent: if the winner exits after removing the branch but before post-delete work, the loser treats that work as complete even though no durable completion is recorded. Running these idempotent post-delete actions for every attempt, or recording them in a durable retryable job, would avoid this gap.
(Based on your team's feedback about retryable DELETING branch deletes.) .
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At backend/infrahub/core/branch/delete_coordinator.py, line 64:
<comment>A concurrent losing delete can permanently skip proposed-change cancellation and `BranchDeletedEvent`: if the winner exits after removing the branch but before post-delete work, the loser treats that work as complete even though no durable completion is recorded. Running these idempotent post-delete actions for every attempt, or recording them in a durable retryable job, would avoid this gap.
(Based on your team's feedback about retryable DELETING branch deletes.) .</comment>
<file context>
@@ -0,0 +1,87 @@
+
+ result = await self.data_deleter.delete(branch=branch)
+
+ if result.branch_deleted:
+ await self.workflow.submit_workflow(
+ workflow=BRANCH_CANCEL_PROPOSED_CHANGES, context=context, parameters={"branch_name": branch.name}
</file context>
There was a problem hiding this comment.
this is very unlikely. there would have to be 2 branch-delete processes running at the same time and then the winning one would need to die before reaching submit_workflow() or send(), which are the next 2 calls
There was a problem hiding this comment.
0 issues found across 2 files (changes from recent commits).
Confidence score: 5/5
- Automated review surfaced no issues in the provided summaries.
- No files require special attention.
Shadow auto-approve: would not auto-approve. Auto-approval blocked by 4 unresolved issues from previous reviews.
Re-trigger cubic
A harvesting-review run over the week's review threads, restricted to the PRs the in-flight harvests (#10145, #10098) did not read: open and recently-merged PRs across develop, stable, and release-1.11. New rules and knowledge: - mutations.md: retry_db_transaction placement — wrap only rollback-able transaction scopes, skip under a caller-supplied transaction (#10121) - query-pattern.md: READ queries with insert_limit=False need their own LIMIT; auto-paginated reads need a total-order ORDER BY (#10132) - creating-migrations.md: fix data bugs at the migration layer, not in runtime save paths (#10105); per-item error collection (#10132); batch by the memory-bounding unit (#10132) - database-schema.md: generic kinds exist only as labels — n.kind never matches a generic; type concrete-only inputs as list[NodeSchema] (#9805) - events.md: changelog models mask secrets only at construction — post-hoc assignment leaks them into events (#10105) - async-tasks.md: InfrahubBatch is concurrent, not ordered (#10113) - testing.md + testing-python.md rules: wiring tests parse source with ast/inspect instead of instrumenting production code (#10121); poll-don't-sleep for async effects (#10133); branch-attributable removal assertions (#10132); pure-function extraction before skipping the cheap test tier (#10137) - checklist.md: new Settings fields must reach both compose entry points — one generated and CI-checked, one hand-maintained (#10122) - backend/AGENTS.md: new REST endpoints are ask-first — prefer existing GraphQL for SDK needs (#8594); route creating-migrations.md in Guides - pre-ci.md: invoke lint tasks run ruff check --diff, which exits 0 on unfixable violations — added the plain CI-parity ruff check (#10146) - development/grafana/AGENTS.md (new): only defined datasource variables, sweep drill-down links, regenerate the standalone compose (#10153) - docs/AGENTS.md: Excalidraw exports use an opaque white background (#9953) Strengthened in place: - creating-changelog-entries skill: removed the 'most maintenance still gets a fragment' push that contradicted the no-user-facing-effect boundary; added lint/typing config cleanups as a named example (#10146) Paid for by compressing testing.md's nested-dataclass example and query-pattern.md's duplicated accessor/return-properties sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYisRTn3sPygP55cxbVqzG
A harvesting-review run over the week's review threads, restricted to the PRs the in-flight harvests (#10145, #10098) did not read: open and recently-merged PRs across develop, stable, and release-1.11. New rules and knowledge: - mutations.md: retry_db_transaction placement — wrap only rollback-able transaction scopes, skip under a caller-supplied transaction (#10121) - query-pattern.md: READ queries with insert_limit=False need their own LIMIT; auto-paginated reads need a total-order ORDER BY (#10132) - creating-migrations.md: fix data bugs at the migration layer, not in runtime save paths (#10105); per-item error collection (#10132); batch by the memory-bounding unit (#10132) - database-schema.md: generic kinds exist only as labels — n.kind never matches a generic; type concrete-only inputs as list[NodeSchema] (#9805) - events.md: changelog models mask secrets only at construction — post-hoc assignment leaks them into events (#10105) - async-tasks.md: InfrahubBatch is concurrent, not ordered (#10113) - testing.md + testing-python.md rules: wiring tests parse source with ast/inspect instead of instrumenting production code (#10121); poll-don't-sleep for async effects (#10133); branch-attributable removal assertions (#10132); pure-function extraction before skipping the cheap test tier (#10137) - checklist.md: new Settings fields must reach both compose entry points — one generated and CI-checked, one hand-maintained (#10122) - backend/AGENTS.md: new REST endpoints are ask-first — prefer existing GraphQL for SDK needs (#8594); route creating-migrations.md in Guides - pre-ci.md: invoke lint tasks run ruff check --diff, which exits 0 on unfixable violations — added the plain CI-parity ruff check (#10146) - development/grafana/AGENTS.md (new): only defined datasource variables, sweep drill-down links, regenerate the standalone compose (#10153) - docs/AGENTS.md: Excalidraw exports use an opaque white background (#9953) Strengthened in place: - creating-changelog-entries skill: removed the 'most maintenance still gets a fragment' push that contradicted the no-user-facing-effect boundary; added lint/typing config cleanups as a named example (#10146) Paid for by compressing testing.md's nested-dataclass example and query-pattern.md's duplicated accessor/return-properties sections. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LYisRTn3sPygP55cxbVqzG (cherry picked from commit ca2190d)
Why
Deleting a large branch can fail with
Neo.TransientError.General.MemoryPoolOutOfMemoryError. Because the delete commits its writes in batches while the surrounding query does not, the failure leaves the branch stranded inDELETING— filtered out of the branch list, so invisible to the user — with most of its data still in the graph, taking up space with no way to reclaim it.Goal: make a branch delete complete regardless of how large the branch is, by bounding how much transaction memory it needs — and clean up the branches previous failures already stranded.
Non-goals: no change to what a branch delete removes.
Closes #9889
What changed
Behavioral changes:
075_finish_deleting_branches) finishes the job for branches an earlier failure abandoned. Upgrading finds every branch left inDELETING, runs it through the new deleter, and removes the branch node — reclaiming space that was otherwise unreachable, since such a branch cannot be looked up or deleted again through any normal path.Branch.delete()now raisesNotImplementedErrorpointing atBranchDeleter. It is kept as an override rather than removed, because the inheritedStandardNode.deletewould drop theBranchvertex and silently orphan every edge and vertex on the branch — a worse failure than the one being fixed.BranchDeletemutation and thebranch-deleteflow load the branch withignore_deleting=False, so theDELETINGstatus the first attempt leaves behind no longer hides it from the lookup. Previously the retry failed withBranchNotFoundErrorand the migration was the only way out — and it runs once, on upgrade, so any later failure stranded the branch for good. Two consequences to be aware of: the mutation will now accept a delete for a branch whose delete is still running (every query involved is idempotent, but nothing serialises the two runs), and a retry re-emitsBranchDeletedEvent. Making the surrounding flow steps idempotent is not addressed here.Implementation notes:
The single mega-query became a set of bounded queries sequenced from Python by a new
BranchDeletercomponent:DeleteBranchAgnosticRelationshipsQuery/DeleteBranchAgnosticAttributesQuery— the agnostic peers of nodes that exist on no other branch.DeleteBranchEdgesQuery— one batch of edges of a single relationship type, re-run until no edges of that type remain, for each member ofDatabaseEdgeType.Three things drove the design:
WITH DISTINCT elementId(s), elementId(d)followed bycollect(s_id) + collect(d_id): an eagerDISTINCTover pairs (so a node was stored once per edge it had), materialized as element-id strings, then twocollects plus a concatenated copy held at once. The existingCALL {} IN TRANSACTIONSonly bounded the inner writes — everything upstream lived in one outer transaction, which is why the earlier "use smaller transactions" fix didn't help. The replacement has no eager aggregation beyond aDISTINCTbounded by the batch.branchrange index serve the match.MATCH (s)-[r]->(d) WHERE r.branch = $branch_namecan use none of the per-type indexes incore/graph/index.py, so it scanned every edge in the database. One query per type turns that into an index seek, and because deleted entries leave the index, each batch is O(batch) rather than a re-scan from the start.DELETEand both endpoints are re-examined afterwards — a vertex is examined once per edge it had, so the batch that removes its last edge is the one that sees it at degree zero. TheDISTINCTis load-bearing: it forces the batch's edge deletes to complete before the first vertex is examined. It must not become aDETACH DELETE, which would remove branch edges that then never reach a batch of their own, leaving the vertices on their far side unexamined and orphaned.A fourth point applies to the agnostic cleanup specifically. Its batches count Nodes, and one Node can drag an unbounded number of peer vertices into the transaction with it, unlike the edge batches where one row is one edge. So it takes
min(batch_size, 500)rather than the configured batch size: 500 is what this phase used before, and it is a ceiling rather than a fixed size so that an operator who lowers the configured limit to fit a constrained database is not handed a larger batch here than they asked for.The migration reuses the delete path rather than reimplementing it: it loads each stranded branch with
Branch.get_by_name(..., ignore_deleting=False)— the escape hatch that makes these branches reachable at all — and hands it toBranchDeleter.delete(), so it exercises exactly the code a normal delete does. Finding them needs its own query because the shared branch list query filtersDELETINGout unconditionally, so asking it for those branches returns nothing.What stayed the same:
IS_PART_OFedges, which phase 2 destroys. That ordering used to be implicit in one query text and is now spread across Python control flow, so it is stated in the docstrings — and it is what makes an interrupted delete safe to re-run.Migration032still works, viaBranchDeleter.delete_branch_data(), which takes a branch name rather than aBranchbecause the branches it cleans up have noBranchvertex left.Alternatives considered:
DETACH DELETEwas dropped: it cost a full 9M-vertex scan per delete and also deleted pre-existing orphans, a scope change the current version doesn't make.CALL {} IN TRANSACTIONSinstead of a PythonLIMITloop. Not taken: it would stream a scan over the same index its committed batches are mutating, while deleting vertices mid-scan. TheLIMITloop gives each batch a fresh snapshot for the price of one round trip per batch.One deliberate gap:
IS_RESERVEDis the only member ofDatabaseEdgeTypewithout abranchrange index, so its pass is a type scan rather than a seek. In practice it is only ever written on the global branch, so a branch-scoped delete never matches it. Adding the index is a DB index change and was left out of this PR.Local performance testing results
Results — branch of 1,074,217 edges in a 24M-edge database:
mainImpact & rollout
Branch.delete()now raises. The only production caller was thebranch-deleteflow, and every caller in this repo is updated; any out-of-tree caller (Infrahub Enterprise, scripts) must switch toBranchDeleter.database.query_size_limitsetting, which the agnostic-cleanup phase caps at 500 for the reason above.GRAPH_VERSIONgoes to 75, so this requires the usualinfrahub db migrateon upgrade. The migration is a no-op on any database with no branch inDELETING; where there is one, it deletes that branch's data, so expect it to run for as long as a branch delete would — it reports each branch as it starts and the edge count when it finishes, sinceinfrahub db migratesuppresses the deleter's own logging and silence would otherwise look like a hang. A branch that cannot be deleted is named in the migration's errors and does not stop the others. Safe to re-run: an interrupted delete simply resumes.Checklist
uv run towncrier create ...)