-
Notifications
You must be signed in to change notification settings - Fork 56
fix(backend): delete branch data in bounded batches #10132
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3f18a50
f261528
7175415
972e178
b94f3e5
205d938
e2296d3
faeea64
02257cb
3fe3c3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,151 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from dataclasses import dataclass | ||
| from typing import TYPE_CHECKING, Any, Protocol | ||
|
|
||
| from infrahub.core.branch.enums import BranchStatus | ||
| from infrahub.core.constants.database import DatabaseEdgeType | ||
| from infrahub.core.query.branch import ( | ||
| DeleteBranchAgnosticAttributesQuery, | ||
| DeleteBranchAgnosticRelationshipsQuery, | ||
| DeleteBranchEdgesQuery, | ||
| ) | ||
| from infrahub.core.query.standard_node import StandardNodeDeleteQuery | ||
| from infrahub.exceptions import ValidationError | ||
| from infrahub.log import get_logger | ||
|
|
||
| if TYPE_CHECKING: | ||
| from infrahub.core.branch.models import Branch | ||
| from infrahub.database import InfrahubDatabase | ||
|
|
||
| # The agnostic cleanup batches Nodes, and each one can drag an unbounded number of peer vertices | ||
| # into the transaction with it, so its batch is capped low. | ||
| MAX_AGNOSTIC_PEER_BATCH_SIZE = 500 | ||
|
|
||
|
|
||
| @dataclass(frozen=True) | ||
| class BranchDeleteResult: | ||
| """What a delete attempt actually did. | ||
|
|
||
| `branch_deleted` is false when the branch had already been removed by the time this attempt got | ||
| to it, which is how a caller knows not to repeat the work that follows a delete. | ||
| """ | ||
|
|
||
| branch_deleted: bool | ||
| edges_removed: int | ||
|
|
||
|
|
||
| class BranchDataDeleterInterface(Protocol): | ||
| """The database side of a branch delete.""" | ||
|
|
||
| async def delete(self, branch: Branch) -> BranchDeleteResult: ... | ||
|
|
||
|
|
||
| class LoggerInterface(Protocol): | ||
| """Just enough of a logger for progress reporting.""" | ||
|
|
||
| def info(self, message: str, /) -> Any: ... | ||
|
|
||
|
|
||
| class BranchDataDeleter: | ||
| """Remove a branch, every edge belonging to it, and the vertices that only it kept alive. | ||
|
|
||
| The graph work is split into one bounded query per batch so that no single transaction has to | ||
| hold the whole branch in memory. Each query is its own auto-commit transaction, which also means | ||
| an interrupted delete can be resumed by running the whole thing again. | ||
| """ | ||
|
|
||
| def __init__(self, db: InfrahubDatabase, batch_size: int, log: LoggerInterface | None = None) -> None: | ||
| self.db = db | ||
| self.batch_size = batch_size | ||
| self.log = log or get_logger() | ||
|
|
||
| async def delete(self, branch: Branch) -> BranchDeleteResult: | ||
| """Remove the branch's data and then the branch itself. | ||
|
|
||
| Returns whether the Branch object was actually deleted in case multiple processes try to | ||
| delete concurrently so the caller can know which delete really succeeded. | ||
|
|
||
| Raises: | ||
| ValidationError: When the branch is the default branch or an internal one. | ||
|
|
||
| """ | ||
| if branch.is_default: | ||
| raise ValidationError(f"Unable to delete {branch.name} it is the default branch.") | ||
| if branch.is_global: | ||
| raise ValidationError(f"Unable to delete {branch.name} this is an internal branch.") | ||
|
|
||
| if branch.status != BranchStatus.DELETING: | ||
| branch.status = BranchStatus.DELETING | ||
| await branch.save(db=self.db) | ||
|
|
||
| edges_removed = await self.delete_branch_data(branch_name=branch.name) | ||
|
|
||
| query = await StandardNodeDeleteQuery.init(db=self.db, node=branch) | ||
| await query.execute(db=self.db) | ||
| branch_deleted = query.stats.get_counter("nodes_deleted") > 0 | ||
|
|
||
| return BranchDeleteResult(branch_deleted=branch_deleted, edges_removed=edges_removed) | ||
|
|
||
| async def delete_branch_data(self, branch_name: str) -> int: | ||
| """Remove a branch's data without requiring the branch itself to still exist. | ||
|
|
||
| Returns the number of edges removed, so a caller whose own logging is the only thing the | ||
| operator can see is able to report progress. | ||
| """ | ||
| agnostic_edges_count = await self._delete_agnostic_peers(branch_name=branch_name) | ||
| branch_edges_count = await self._delete_edges(branch_name=branch_name) | ||
| return agnostic_edges_count + branch_edges_count | ||
|
|
||
| async def _delete_agnostic_peers(self, branch_name: str) -> int: | ||
| """Drop the agnostic attributes and relationships of Nodes that exist on no other branch. | ||
|
|
||
| Both queries locate those Nodes through the branch's IS_PART_OF edges, so this has to | ||
| finish before the edge deletion starts removing them. Resuming a delete that failed part | ||
| way through this stage is safe for the same reason: no IS_PART_OF edge has been touched yet. | ||
|
|
||
| Returns the number of edges removed, which is every edge of the peers detached here, not | ||
| only the agnostic ones that led to them. | ||
| """ | ||
| batch_size = min(self.batch_size, MAX_AGNOSTIC_PEER_BATCH_SIZE) | ||
|
|
||
| relationships_query = await DeleteBranchAgnosticRelationshipsQuery.init( | ||
| db=self.db, branch_name=branch_name, batch_size=batch_size | ||
| ) | ||
| await relationships_query.execute(db=self.db) | ||
|
|
||
| attributes_query = await DeleteBranchAgnosticAttributesQuery.init( | ||
| db=self.db, branch_name=branch_name, batch_size=batch_size | ||
| ) | ||
| await attributes_query.execute(db=self.db) | ||
|
|
||
| edges_removed = relationships_query.stats.get_counter( | ||
| "relationships_deleted" | ||
| ) + attributes_query.stats.get_counter("relationships_deleted") | ||
| if edges_removed: | ||
| self.log.info( | ||
| f"Deleted agnostic peers of nodes only on branch '{branch_name}', {edges_removed} edge(s) removed" | ||
| ) | ||
| return edges_removed | ||
|
|
||
| async def _delete_edges(self, branch_name: str) -> int: | ||
| edges_removed = 0 | ||
| for edge_type in DatabaseEdgeType: | ||
| deleted_total = 0 | ||
| while True: | ||
| # A fresh query per batch: the stats counters accumulate per instance, so a reused | ||
| # one would never report zero again and the loop would not end. | ||
| query = await DeleteBranchEdgesQuery.init( | ||
| db=self.db, branch_name=branch_name, edge_type=edge_type, batch_size=self.batch_size | ||
| ) | ||
| await query.execute(db=self.db) | ||
| deleted = query.deleted_edge_count() | ||
| if not deleted: | ||
| break | ||
| deleted_total += deleted | ||
|
|
||
| if deleted_total: | ||
| edges_removed += deleted_total | ||
| self.log.info(f"Deleted {deleted_total} {edge_type.value} edge(s) on branch '{branch_name}'") | ||
|
|
||
| return edges_removed | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| from __future__ import annotations | ||
|
|
||
| from typing import TYPE_CHECKING, Protocol | ||
|
|
||
| from infrahub.events.branch_action import BranchDeletedEvent | ||
| from infrahub.events.models import EventMeta | ||
| from infrahub.exceptions import ValidationError | ||
| from infrahub.workflows.catalogue import BRANCH_CANCEL_PROPOSED_CHANGES, GIT_REPOSITORIES_DELETE_BRANCH | ||
|
|
||
| if TYPE_CHECKING: | ||
| from infrahub.context import InfrahubContext | ||
| from infrahub.core.branch.data_deleter import BranchDataDeleterInterface, BranchDeleteResult, LoggerInterface | ||
| from infrahub.core.branch.models import Branch | ||
| from infrahub.services.adapters.event import InfrahubEventService | ||
| from infrahub.services.adapters.workflow import InfrahubWorkflow | ||
|
|
||
|
|
||
| class DiffFreezerInterface(Protocol): | ||
| """Interface for freezing diffs.""" | ||
|
|
||
| async def freeze_diffs_for_branch(self, branch_name: str) -> None: ... | ||
|
|
||
|
|
||
| class BranchDeleteOrchestrator: | ||
| """Delete a branch and do the work that follows from it. | ||
|
|
||
| Holds no database of its own: the deletion is delegated, which is what keeps the ordering and the | ||
| post-delete decisions here testable without one. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| data_deleter: BranchDataDeleterInterface, | ||
| diff_freezer: DiffFreezerInterface, | ||
| event_service: InfrahubEventService, | ||
| workflow: InfrahubWorkflow, | ||
| log: LoggerInterface, | ||
| global_branch: Branch, | ||
| delete_git_branch_after_merge: bool, | ||
| ) -> None: | ||
| self.data_deleter = data_deleter | ||
| self.diff_freezer = diff_freezer | ||
| self.event_service = event_service | ||
| self.workflow = workflow | ||
| self.log = log | ||
| self.global_branch = global_branch | ||
| self.delete_git_branch_after_merge = delete_git_branch_after_merge | ||
|
|
||
| async def delete( | ||
| self, | ||
| branch: Branch, | ||
| context: InfrahubContext, | ||
| delete_from_git: bool = False, | ||
| proposed_change_id: str | None = None, | ||
| ) -> BranchDeleteResult: | ||
| """Remove the branch, then cancel its proposed changes, announce it, and drop its Git branch. | ||
|
|
||
| Raises: | ||
| ValidationError: When the branch is the default branch or an internal one. | ||
|
|
||
| """ | ||
| # Before the freeze, not after: a refused delete has to leave the branch's diffs alone. | ||
| if branch.is_default: | ||
| raise ValidationError(f"Unable to delete {branch.name} it is the default branch.") | ||
| if branch.is_global: | ||
| raise ValidationError(f"Unable to delete {branch.name} this is an internal branch.") | ||
|
|
||
| # Freezing has to precede the deletion, which takes away the branch name they are found by. | ||
| await self.diff_freezer.freeze_diffs_for_branch(branch_name=branch.name) | ||
|
cubic-dev-ai[bot] marked this conversation as resolved.
|
||
|
|
||
| result = await self.data_deleter.delete(branch=branch) | ||
|
|
||
| if result.branch_deleted: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. P1: A concurrent losing delete can permanently skip proposed-change cancellation and (Based on your team's feedback about retryable DELETING branch deletes.) . Prompt for AI agents
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
| await self.workflow.submit_workflow( | ||
| workflow=BRANCH_CANCEL_PROPOSED_CHANGES, context=context, parameters={"branch_name": branch.name} | ||
| ) | ||
| await self.event_service.send( | ||
| event=BranchDeletedEvent( | ||
| branch_name=branch.name, | ||
| branch_id=str(branch.uuid), | ||
| sync_with_git=branch.sync_with_git, | ||
| meta=EventMeta.from_context(context=context.to_event_context(), branch=self.global_branch), | ||
| proposed_change_id=proposed_change_id, | ||
| ) | ||
| ) | ||
| else: | ||
| # Another attempt removed the branch, so the work above is already its responsibility. | ||
| self.log.info(f"Branch '{branch.name}' was already deleted") | ||
|
|
||
| # Always execute in case concurrent delete process with delete_from_git=False won the delete race. | ||
| if (self.delete_git_branch_after_merge or delete_from_git) and branch.sync_with_git: | ||
| await self.workflow.submit_workflow( | ||
| workflow=GIT_REPOSITORIES_DELETE_BRANCH, context=context, parameters={"branch": branch.name} | ||
| ) | ||
|
|
||
| return result | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| GRAPH_VERSION = 74 | ||
| GRAPH_VERSION = 75 |
Uh oh!
There was an error while loading. Please reload this page.