infrahub-server 1.7.0rc0__py3-none-any.whl → 1.7.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. infrahub/actions/gather.py +2 -2
  2. infrahub/api/query.py +3 -2
  3. infrahub/api/schema.py +5 -0
  4. infrahub/api/transformation.py +3 -3
  5. infrahub/cli/db.py +6 -2
  6. infrahub/computed_attribute/gather.py +2 -0
  7. infrahub/config.py +2 -2
  8. infrahub/core/attribute.py +21 -2
  9. infrahub/core/branch/models.py +11 -117
  10. infrahub/core/branch/tasks.py +7 -3
  11. infrahub/core/diff/merger/merger.py +5 -1
  12. infrahub/core/diff/model/path.py +43 -0
  13. infrahub/core/graph/__init__.py +1 -1
  14. infrahub/core/graph/index.py +2 -0
  15. infrahub/core/initialization.py +2 -1
  16. infrahub/core/ipam/resource_allocator.py +229 -0
  17. infrahub/core/migrations/graph/__init__.py +10 -0
  18. infrahub/core/migrations/graph/m014_remove_index_attr_value.py +3 -2
  19. infrahub/core/migrations/graph/m015_diff_format_update.py +3 -2
  20. infrahub/core/migrations/graph/m016_diff_delete_bug_fix.py +3 -2
  21. infrahub/core/migrations/graph/m017_add_core_profile.py +6 -4
  22. infrahub/core/migrations/graph/m018_uniqueness_nulls.py +3 -4
  23. infrahub/core/migrations/graph/m020_duplicate_edges.py +3 -3
  24. infrahub/core/migrations/graph/m025_uniqueness_nulls.py +3 -4
  25. infrahub/core/migrations/graph/m026_0000_prefix_fix.py +4 -5
  26. infrahub/core/migrations/graph/m028_delete_diffs.py +3 -2
  27. infrahub/core/migrations/graph/m029_duplicates_cleanup.py +3 -2
  28. infrahub/core/migrations/graph/m031_check_number_attributes.py +4 -3
  29. infrahub/core/migrations/graph/m032_cleanup_orphaned_branch_relationships.py +3 -2
  30. infrahub/core/migrations/graph/m034_find_orphaned_schema_fields.py +3 -2
  31. infrahub/core/migrations/graph/m035_orphan_relationships.py +3 -3
  32. infrahub/core/migrations/graph/m036_drop_attr_value_index.py +3 -2
  33. infrahub/core/migrations/graph/m037_index_attr_vals.py +3 -2
  34. infrahub/core/migrations/graph/m038_redo_0000_prefix_fix.py +4 -5
  35. infrahub/core/migrations/graph/m039_ipam_reconcile.py +3 -2
  36. infrahub/core/migrations/graph/m041_deleted_dup_edges.py +3 -2
  37. infrahub/core/migrations/graph/m042_profile_attrs_in_db.py +5 -4
  38. infrahub/core/migrations/graph/m043_create_hfid_display_label_in_db.py +12 -5
  39. infrahub/core/migrations/graph/m044_backfill_hfid_display_label_in_db.py +15 -4
  40. infrahub/core/migrations/graph/m045_backfill_hfid_display_label_in_db_profile_template.py +10 -4
  41. infrahub/core/migrations/graph/m046_fill_agnostic_hfid_display_labels.py +6 -5
  42. infrahub/core/migrations/graph/m047_backfill_or_null_display_label.py +19 -5
  43. infrahub/core/migrations/graph/m048_undelete_rel_props.py +6 -4
  44. infrahub/core/migrations/graph/m049_remove_is_visible_relationship.py +3 -3
  45. infrahub/core/migrations/graph/m050_backfill_vertex_metadata.py +3 -3
  46. infrahub/core/migrations/graph/m051_subtract_branched_from_microsecond.py +39 -0
  47. infrahub/core/migrations/graph/m052_fix_global_branch_level.py +51 -0
  48. infrahub/core/migrations/graph/m053_fix_branch_level_zero.py +61 -0
  49. infrahub/core/migrations/graph/m054_cleanup_orphaned_nodes.py +87 -0
  50. infrahub/core/migrations/graph/m055_remove_webhook_validate_certificates_default.py +86 -0
  51. infrahub/core/migrations/runner.py +6 -3
  52. infrahub/core/migrations/schema/attribute_kind_update.py +8 -11
  53. infrahub/core/migrations/schema/attribute_supports_profile.py +3 -8
  54. infrahub/core/migrations/schema/models.py +8 -0
  55. infrahub/core/migrations/schema/node_attribute_add.py +24 -29
  56. infrahub/core/migrations/schema/tasks.py +7 -1
  57. infrahub/core/migrations/shared.py +37 -30
  58. infrahub/core/node/__init__.py +2 -1
  59. infrahub/core/node/lock_utils.py +23 -2
  60. infrahub/core/node/resource_manager/ip_address_pool.py +5 -11
  61. infrahub/core/node/resource_manager/ip_prefix_pool.py +5 -21
  62. infrahub/core/node/resource_manager/number_pool.py +109 -39
  63. infrahub/core/query/__init__.py +7 -1
  64. infrahub/core/query/branch.py +18 -2
  65. infrahub/core/query/ipam.py +629 -40
  66. infrahub/core/query/node.py +128 -0
  67. infrahub/core/query/resource_manager.py +114 -1
  68. infrahub/core/relationship/model.py +9 -3
  69. infrahub/core/schema/attribute_parameters.py +28 -1
  70. infrahub/core/schema/attribute_schema.py +9 -2
  71. infrahub/core/schema/definitions/core/webhook.py +0 -1
  72. infrahub/core/schema/definitions/internal.py +7 -4
  73. infrahub/core/schema/manager.py +50 -38
  74. infrahub/core/validators/attribute/kind.py +5 -2
  75. infrahub/core/validators/determiner.py +4 -0
  76. infrahub/graphql/analyzer.py +3 -1
  77. infrahub/graphql/app.py +7 -10
  78. infrahub/graphql/execution.py +95 -0
  79. infrahub/graphql/manager.py +8 -2
  80. infrahub/graphql/mutations/proposed_change.py +15 -0
  81. infrahub/graphql/parser.py +10 -7
  82. infrahub/graphql/queries/ipam.py +20 -25
  83. infrahub/graphql/queries/search.py +29 -9
  84. infrahub/lock.py +7 -0
  85. infrahub/proposed_change/tasks.py +2 -0
  86. infrahub/services/adapters/cache/redis.py +7 -0
  87. infrahub/services/adapters/http/httpx.py +27 -0
  88. infrahub/trigger/catalogue.py +2 -0
  89. infrahub/trigger/models.py +73 -4
  90. infrahub/trigger/setup.py +1 -1
  91. infrahub/trigger/system.py +36 -0
  92. infrahub/webhook/models.py +4 -2
  93. infrahub/webhook/tasks.py +2 -2
  94. infrahub/workflows/initialization.py +2 -2
  95. infrahub_sdk/analyzer.py +2 -2
  96. infrahub_sdk/branch.py +12 -39
  97. infrahub_sdk/checks.py +4 -4
  98. infrahub_sdk/client.py +36 -0
  99. infrahub_sdk/ctl/cli_commands.py +2 -1
  100. infrahub_sdk/ctl/graphql.py +15 -4
  101. infrahub_sdk/ctl/utils.py +2 -2
  102. infrahub_sdk/enums.py +6 -0
  103. infrahub_sdk/graphql/renderers.py +21 -0
  104. infrahub_sdk/graphql/utils.py +85 -0
  105. infrahub_sdk/node/attribute.py +12 -2
  106. infrahub_sdk/node/constants.py +11 -0
  107. infrahub_sdk/node/metadata.py +69 -0
  108. infrahub_sdk/node/node.py +65 -14
  109. infrahub_sdk/node/property.py +3 -0
  110. infrahub_sdk/node/related_node.py +24 -1
  111. infrahub_sdk/node/relationship.py +10 -1
  112. infrahub_sdk/operation.py +2 -2
  113. infrahub_sdk/schema/repository.py +1 -2
  114. infrahub_sdk/transforms.py +2 -2
  115. infrahub_sdk/types.py +18 -2
  116. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/METADATA +8 -8
  117. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/RECORD +123 -114
  118. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/entry_points.txt +0 -1
  119. infrahub_testcontainers/docker-compose-cluster.test.yml +16 -10
  120. infrahub_testcontainers/docker-compose.test.yml +11 -10
  121. infrahub_testcontainers/performance_test.py +1 -1
  122. infrahub/pools/address.py +0 -16
  123. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/WHEEL +0 -0
  124. {infrahub_server-1.7.0rc0.dist-info → infrahub_server-1.7.2.dist-info}/licenses/LICENSE.txt +0 -0
@@ -1,6 +1,7 @@
1
1
  import hashlib
2
2
  from typing import TYPE_CHECKING
3
3
 
4
+ from infrahub.core.constants import RelationshipCardinality
4
5
  from infrahub.core.node import Node
5
6
  from infrahub.core.schema import GenericSchema
6
7
  from infrahub.core.schema.schema_branch import SchemaBranch
@@ -10,6 +11,7 @@ if TYPE_CHECKING:
10
11
 
11
12
 
12
13
  RESOURCE_POOL_LOCK_NAMESPACE = "resource_pool"
14
+ RELATIONSHIP_COUNT_LOCK_NAMESPACE = "relationship_count"
13
15
 
14
16
 
15
17
  def _get_kinds_to_lock_on_object_mutation(kind: str, schema_branch: SchemaBranch) -> list[str]:
@@ -55,7 +57,8 @@ def _hash(value: str) -> str:
55
57
  def get_lock_names_on_object_mutation(node: Node, schema_branch: SchemaBranch) -> list[str]:
56
58
  """
57
59
  Return lock names for object on which we want to avoid concurrent mutation (create/update).
58
- Lock names include kind, some generic kinds, resource pool ids, and values of attributes of corresponding uniqueness constraints.
60
+ Lock names include kind, some generic kinds, resource pool ids, peer ids for cardinality one relationships,
61
+ and values of attributes of corresponding uniqueness constraints.
59
62
  """
60
63
 
61
64
  lock_names: set[str] = set()
@@ -66,13 +69,31 @@ def get_lock_names_on_object_mutation(node: Node, schema_branch: SchemaBranch) -
66
69
  if attribute is not None and getattr(attribute, "from_pool", None) and "id" in attribute.from_pool:
67
70
  lock_names.add(f"{RESOURCE_POOL_LOCK_NAMESPACE}.{attribute.from_pool['id']}")
68
71
 
69
- # Check if relationships allocate resources
72
+ # Check if relationships allocate resources or have cardinality one constraint
70
73
  for rel_name in node._relationships:
71
74
  rel_manager: RelationshipManager = getattr(node, rel_name)
72
75
  for rel in rel_manager._relationships:
73
76
  if rel.from_pool and "id" in rel.from_pool:
74
77
  lock_names.add(f"{RESOURCE_POOL_LOCK_NAMESPACE}.{rel.from_pool['id']}")
75
78
 
79
+ peer_id = rel.peer_id
80
+ if not peer_id or not rel.schema.identifier:
81
+ continue
82
+
83
+ # Check if this node's relationship has cardinality one or max/min_count constraint
84
+ # This prevents concurrent updates to the same node's constrained relationship
85
+ if rel.schema.cardinality == RelationshipCardinality.ONE or rel.schema.max_count or rel.schema.min_count:
86
+ lock_names.add(f"{RELATIONSHIP_COUNT_LOCK_NAMESPACE}.{rel.schema.identifier}.{node.id}")
87
+
88
+ # Check if the peer has count constraints on the reverse relationship
89
+ # This includes cardinality one, max_count, and min_count constraints
90
+ peer_schema = schema_branch.get(name=rel.schema.peer, duplicate=False)
91
+ peer_rel = peer_schema.get_relationship_by_identifier(id=rel.schema.identifier, raise_on_error=False)
92
+ if peer_rel and (
93
+ peer_rel.cardinality == RelationshipCardinality.ONE or peer_rel.max_count or peer_rel.min_count
94
+ ):
95
+ lock_names.add(f"{RELATIONSHIP_COUNT_LOCK_NAMESPACE}.{rel.schema.identifier}.{peer_id}")
96
+
76
97
  lock_kinds = _get_kinds_to_lock_on_object_mutation(node.get_kind(), schema_branch)
77
98
  for kind in lock_kinds:
78
99
  schema = schema_branch.get(name=kind, duplicate=False)
@@ -6,13 +6,12 @@ from typing import TYPE_CHECKING, Any
6
6
  from infrahub import lock
7
7
  from infrahub.core import registry
8
8
  from infrahub.core.ipam.reconciler import IpamReconciler
9
- from infrahub.core.query.ipam import get_ip_addresses
9
+ from infrahub.core.ipam.resource_allocator import IPAMResourceAllocator
10
10
  from infrahub.core.query.resource_manager import (
11
11
  IPAddressPoolGetReserved,
12
12
  IPAddressPoolSetReserved,
13
13
  )
14
14
  from infrahub.exceptions import PoolExhaustedError, ValidationError
15
- from infrahub.pools.address import get_available
16
15
 
17
16
  from .. import Node
18
17
  from ..lock_utils import RESOURCE_POOL_LOCK_NAMESPACE
@@ -88,6 +87,7 @@ class CoreIPAddressPool(Node):
88
87
  async def get_next(self, db: InfrahubDatabase, prefixlen: int | None = None) -> IPAddressType:
89
88
  resources = await self.resources.get_peers(db=db) # type: ignore[attr-defined]
90
89
  ip_namespace = await self.ip_namespace.get_peer(db=db) # type: ignore[attr-defined]
90
+ allocator = IPAMResourceAllocator(db=db, namespace=ip_namespace, branch=self._branch, branch_agnostic=True)
91
91
 
92
92
  try:
93
93
  weighted_resources = sorted(resources.values(), key=lambda r: r.allocation_weight.value or 0, reverse=True)
@@ -101,18 +101,12 @@ class CoreIPAddressPool(Node):
101
101
  if not ip_prefix.prefixlen <= prefix_length <= ip_prefix.max_prefixlen:
102
102
  raise ValidationError(input_value="Invalid prefix length for current selected prefix")
103
103
 
104
- addresses = await get_ip_addresses(
105
- db=db, ip_prefix=ip_prefix, namespace=ip_namespace, branch=self._branch, branch_agnostic=True
106
- )
107
-
108
- available = get_available(
109
- network=ip_prefix,
110
- addresses=[ip.address for ip in addresses],
104
+ next_address = await allocator.get_next_address(
105
+ ip_prefix=ip_prefix,
111
106
  is_pool=resource.is_pool.value, # type: ignore[attr-defined]
112
107
  )
113
108
 
114
- if available:
115
- next_address = available.iter_cidrs()[0]
109
+ if next_address:
116
110
  return ipaddress.ip_interface(f"{next_address.ip}/{prefix_length}")
117
111
 
118
112
  raise PoolExhaustedError("There are no more addresses available in this pool.")
@@ -3,18 +3,15 @@ from __future__ import annotations
3
3
  import ipaddress
4
4
  from typing import TYPE_CHECKING, Any
5
5
 
6
- from netaddr import IPSet
7
-
8
6
  from infrahub import lock
9
7
  from infrahub.core import registry
10
8
  from infrahub.core.ipam.reconciler import IpamReconciler
11
- from infrahub.core.query.ipam import get_subnets
9
+ from infrahub.core.ipam.resource_allocator import IPAMResourceAllocator
12
10
  from infrahub.core.query.resource_manager import (
13
11
  PrefixPoolGetReserved,
14
12
  PrefixPoolSetReserved,
15
13
  )
16
14
  from infrahub.exceptions import ValidationError
17
- from infrahub.pools.prefix import get_next_available_prefix
18
15
 
19
16
  from .. import Node
20
17
  from ..lock_utils import RESOURCE_POOL_LOCK_NAMESPACE
@@ -96,6 +93,7 @@ class CoreIPPrefixPool(Node):
96
93
  async def get_next(self, db: InfrahubDatabase, prefixlen: int) -> IPNetworkType:
97
94
  resources = await self.resources.get_peers(db=db) # type: ignore[attr-defined]
98
95
  ip_namespace = await self.ip_namespace.get_peer(db=db) # type: ignore[attr-defined]
96
+ allocator = IPAMResourceAllocator(db=db, namespace=ip_namespace, branch=self._branch, branch_agnostic=True)
99
97
 
100
98
  try:
101
99
  weighted_resources = sorted(resources.values(), key=lambda r: r.allocation_weight.value or 0, reverse=True)
@@ -103,23 +101,9 @@ class CoreIPPrefixPool(Node):
103
101
  weighted_resources = list(resources.values())
104
102
 
105
103
  for resource in weighted_resources:
106
- subnets = await get_subnets(
107
- db=db,
108
- ip_prefix=ipaddress.ip_network(resource.prefix.value), # type: ignore[attr-defined]
109
- namespace=ip_namespace,
110
- branch=self._branch,
111
- branch_agnostic=True,
112
- )
113
-
114
- pool = IPSet([resource.prefix.value])
115
- for subnet in subnets:
116
- pool.remove(addr=str(subnet.prefix))
117
-
118
- try:
119
- prefix_ver = ipaddress.ip_network(resource.prefix.value).version
120
- next_available = get_next_available_prefix(pool=pool, prefix_length=prefixlen, prefix_ver=prefix_ver)
104
+ resource_prefix = ipaddress.ip_network(resource.prefix.value) # type: ignore[attr-defined]
105
+ next_available = await allocator.get_next_prefix(ip_prefix=resource_prefix, target_prefix_length=prefixlen)
106
+ if next_available:
121
107
  return next_available
122
- except ValueError:
123
- continue
124
108
 
125
109
  raise IndexError("No more resources available")
@@ -4,7 +4,12 @@ from typing import TYPE_CHECKING
4
4
 
5
5
  from infrahub import lock
6
6
  from infrahub.core import registry
7
- from infrahub.core.query.resource_manager import NumberPoolGetReserved, NumberPoolGetUsed, NumberPoolSetReserved
7
+ from infrahub.core.query.resource_manager import (
8
+ NumberPoolGetFree,
9
+ NumberPoolGetReserved,
10
+ NumberPoolGetUsed,
11
+ NumberPoolSetReserved,
12
+ )
8
13
  from infrahub.core.schema.attribute_parameters import NumberAttributeParameters
9
14
  from infrahub.exceptions import PoolExhaustedError
10
15
 
@@ -47,6 +52,28 @@ class CoreNumberPool(Node):
47
52
  used = [result.value for result in query.iter_results()]
48
53
  return [item for item in used if item is not None]
49
54
 
55
+ async def get_free(
56
+ self, db: InfrahubDatabase, branch: Branch, min_value: int | None = None, max_value: int | None = None
57
+ ) -> int | None:
58
+ """Returns the next free number in the pool.
59
+
60
+ Args:
61
+ db: Database connection.
62
+ branch: Branch to query.
63
+ min_value: Minimum value to start searching from.
64
+ max_value: Maximum value to search up to.
65
+
66
+ Returns:
67
+ The next free number, or None if no free numbers are available.
68
+ """
69
+
70
+ query = await NumberPoolGetFree.init(
71
+ db=db, branch=branch, pool=self, branch_agnostic=True, min_value=min_value, max_value=max_value
72
+ )
73
+ await query.execute(db=db)
74
+
75
+ return query.get_result_value()
76
+
50
77
  async def reserve(self, db: InfrahubDatabase, number: int, identifier: str, at: Timestamp | None = None) -> None:
51
78
  """Reserve a number in the pool for a specific identifier."""
52
79
 
@@ -85,51 +112,94 @@ class CoreNumberPool(Node):
85
112
  return number
86
113
 
87
114
  async def get_next(self, db: InfrahubDatabase, branch: Branch, attribute: AttributeSchema) -> int:
88
- taken = await self.get_used(db=db, branch=branch)
115
+ """Get the next available number from the pool.
89
116
 
90
- next_number = find_next_free(
91
- start=self.start_range.value, # type: ignore[attr-defined]
92
- end=self.end_range.value, # type: ignore[attr-defined]
93
- taken=taken,
94
- parameters=attribute.parameters if isinstance(attribute.parameters, NumberAttributeParameters) else None,
95
- )
96
- if next_number is None:
97
- raise PoolExhaustedError("There are no more values available in this pool.")
98
-
99
- return next_number
117
+ Args:
118
+ db: Database connection.
119
+ branch: Branch to query.
120
+ attribute: Attribute schema that may contain NumberAttributeParameters constraints.
100
121
 
101
- async def get_next_many(
102
- self, db: InfrahubDatabase, quantity: int, branch: Branch, attribute: AttributeSchema
103
- ) -> list[int]:
104
- taken = await self.get_used(db=db, branch=branch)
122
+ Returns:
123
+ The next available number that satisfies all constraints.
105
124
 
106
- allocated: list[int] = []
125
+ Raises:
126
+ PoolExhaustedError: If no valid numbers are available in the pool.
127
+ """
128
+ parameters = attribute.parameters if isinstance(attribute.parameters, NumberAttributeParameters) else None
107
129
 
108
- for _ in range(quantity):
109
- next_number = find_next_free(
110
- start=self.start_range.value, # type: ignore[attr-defined]
111
- end=self.end_range.value, # type: ignore[attr-defined]
112
- taken=list(set(taken) | set(allocated)),
113
- parameters=attribute.parameters
114
- if isinstance(attribute.parameters, NumberAttributeParameters)
115
- else None,
116
- )
117
- if next_number is None:
118
- raise PoolExhaustedError(
119
- f"There are no more values available in this pool, couldn't allocate {quantity} values, only {len(allocated)} available."
120
- )
130
+ # Extract exclusion constraints from the attribute parameters
131
+ excluded_values: set[int] = set()
132
+ excluded_ranges: list[tuple[int, int]] = []
121
133
 
122
- allocated.append(next_number)
134
+ if parameters:
135
+ excluded_values = set(parameters.get_excluded_single_values())
136
+ excluded_ranges = parameters.get_excluded_ranges()
123
137
 
124
- return allocated
138
+ # Compute effective range by combining pool range with min/max constraints
139
+ pool_start = self.start_range.value # type: ignore[attr-defined]
140
+ pool_end = self.end_range.value # type: ignore[attr-defined]
125
141
 
142
+ effective_start = pool_start
143
+ effective_end = pool_end
126
144
 
127
- def find_next_free(start: int, end: int, taken: list[int], parameters: NumberAttributeParameters | None) -> int | None:
128
- used_set = set(taken)
145
+ if parameters:
146
+ if parameters.min_value is not None:
147
+ effective_start = max(effective_start, parameters.min_value)
148
+ if parameters.max_value is not None:
149
+ effective_end = min(effective_end, parameters.max_value)
129
150
 
130
- for num in range(start, end + 1):
131
- if num not in used_set:
132
- if parameters is None or parameters.is_valid_value(num):
133
- return num
151
+ # Check if the effective range is valid
152
+ if effective_start > effective_end:
153
+ raise PoolExhaustedError("There are no more values available in this pool.")
134
154
 
135
- return None
155
+ def skip_excluded(value: int) -> int | None:
156
+ """Skip past any excluded values/ranges starting from value.
157
+
158
+ Returns the next non-excluded value, or None if we exceed effective_end.
159
+ """
160
+ current = value
161
+ while current <= effective_end:
162
+ # Check if in an excluded range and skip past it
163
+ in_range = False
164
+ for range_start, range_end in excluded_ranges:
165
+ if range_start <= current <= range_end:
166
+ current = range_end + 1
167
+ in_range = True
168
+ break
169
+ if in_range:
170
+ continue
171
+
172
+ # Check if it's an excluded single value
173
+ if current in excluded_values:
174
+ current += 1
175
+ continue
176
+
177
+ # Found a non-excluded value
178
+ return current
179
+
180
+ return None
181
+
182
+ # Skip any excluded values at the start
183
+ first_valid = skip_excluded(effective_start)
184
+ if first_valid is None:
185
+ raise PoolExhaustedError("There are no more values available in this pool.")
186
+ min_value = first_valid
187
+
188
+ # Re-run the query until we find a non-excluded value or exhaust the pool
189
+ while True:
190
+ candidate = await self.get_free(db=db, branch=branch, min_value=min_value, max_value=effective_end)
191
+ if candidate is None:
192
+ raise PoolExhaustedError("There are no more values available in this pool.")
193
+
194
+ # Check if candidate is excluded (single value or range)
195
+ next_valid = skip_excluded(candidate)
196
+ if next_valid is None:
197
+ raise PoolExhaustedError("There are no more values available in this pool.")
198
+
199
+ if next_valid != candidate:
200
+ # Candidate was excluded, re-query starting from next valid point
201
+ min_value = next_valid
202
+ continue
203
+
204
+ # Candidate passed all checks
205
+ return candidate
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  from collections import defaultdict
4
4
  from dataclasses import dataclass, field
5
5
  from enum import Enum
6
+ from functools import lru_cache
6
7
  from typing import TYPE_CHECKING, Any, Callable, Generator, Iterator, TypeVar
7
8
 
8
9
  import ujson
@@ -417,6 +418,11 @@ class Query:
417
418
  Right now it's mainly used to add more labels to the metrics."""
418
419
  return {}
419
420
 
421
+ @staticmethod
422
+ @lru_cache(maxsize=1024)
423
+ def _split_query_lines(query: str) -> list[str]:
424
+ return [line.strip() for line in query.split("\n") if line.strip()]
425
+
420
426
  def add_to_query(self, query: str | list[str]) -> None:
421
427
  """Add a new section at the end of the query.
422
428
 
@@ -427,7 +433,7 @@ class Query:
427
433
  for item in query:
428
434
  self.add_to_query(query=item)
429
435
  else:
430
- self.query_lines.extend([line.strip() for line in query.split("\n") if line.strip()])
436
+ self.query_lines.extend(self._split_query_lines(query=query))
431
437
 
432
438
  def add_subquery(self, subquery: str, node_alias: str, with_clause: str | None = None) -> None:
433
439
  self.add_to_query(f"CALL ({node_alias}) {{")
@@ -24,15 +24,31 @@ class DeleteBranchRelationshipsQuery(Query):
24
24
  async def query_init(self, db: InfrahubDatabase, **kwargs: Any) -> None: # noqa: ARG002
25
25
  query = """
26
26
  // --------------
27
- // for every Node created on this branch (it's about to be deleted), find any agnostic relationships
28
- // connected to the Node and delete them
27
+ // for every Node that only exists on this branch (it's about to be deleted),
28
+ // find any agnostic relationships or attributes connected to the Node and delete them
29
29
  // --------------
30
30
  OPTIONAL MATCH (:Root)<-[e:IS_PART_OF {status: "active"}]-(n:Node)
31
31
  WHERE e.branch = $branch_name
32
+ // does the node only exist on this branch?
32
33
  CALL (n) {
34
+ OPTIONAL MATCH (n)-[ipo:IS_PART_OF {status: "active"}]->(:Root)
35
+ WHERE ipo.branch <> $branch_name
36
+ LIMIT 1
37
+ RETURN ipo IS NOT NULL AS node_exists_on_other_branch
38
+ }
39
+ // if so, delete any linked agnostic relationships or attributes
40
+ CALL (n, node_exists_on_other_branch) {
41
+ WITH n, node_exists_on_other_branch
42
+ WHERE node_exists_on_other_branch = FALSE
33
43
  OPTIONAL MATCH (n)-[:IS_RELATED {branch: $global_branch_name}]-(rel:Relationship)
34
44
  DETACH DELETE rel
35
45
  } IN TRANSACTIONS OF 500 ROWS
46
+ CALL (n, node_exists_on_other_branch) {
47
+ WITH n, node_exists_on_other_branch
48
+ WHERE node_exists_on_other_branch = FALSE
49
+ OPTIONAL MATCH (n)-[:HAS_ATTRIBUTE {branch: $global_branch_name}]-(attr:Attribute)
50
+ DETACH DELETE attr
51
+ } IN TRANSACTIONS OF 500 ROWS
36
52
 
37
53
  // reduce the results to a single row
38
54
  WITH 1 AS one