Skip to content

Commit 1aec308

Browse files
authored
Merge pull request #5967 from opsmill/bkr-merge-stable-release1.2-20250307
Merge stable to release1.2 (2025-03-07)
2 parents 52cf5f9 + 5a1d1d7 commit 1aec308

File tree

17 files changed

+541
-42
lines changed

17 files changed

+541
-42
lines changed

.vale/styles/spelling-exceptions.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ regenerate_host_artifact
109109
repo
110110
REST
111111
resources
112+
schema's
112113
schema_mapping
113114
sdk
114115
subcommand

backend/infrahub/core/migrations/graph/__init__.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
from .m017_add_core_profile import Migration017
2222
from .m018_uniqueness_nulls import Migration018
2323
from .m019_restore_rels_to_time import Migration019
24-
from .m020_add_generate_template_attr import Migration020
24+
from .m020_duplicate_edges import Migration020
25+
from .m021_add_generate_template_attr import Migration021
2526

2627
if TYPE_CHECKING:
2728
from infrahub.core.root import Root
@@ -49,6 +50,7 @@
4950
Migration018,
5051
Migration019,
5152
Migration020,
53+
Migration021,
5254
]
5355

5456

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING, Any, Sequence
4+
5+
from infrahub.core.constants.database import DatabaseEdgeType
6+
from infrahub.core.migrations.shared import GraphMigration, MigrationResult
7+
from infrahub.log import get_logger
8+
9+
from ...query import Query, QueryType
10+
11+
if TYPE_CHECKING:
12+
from infrahub.database import InfrahubDatabase
13+
14+
log = get_logger()
15+
16+
17+
class DeleteDuplicateHasValueEdgesQuery(Query):
18+
name = "delete_duplicate_has_value_edges"
19+
type = QueryType.WRITE
20+
insert_return = False
21+
22+
async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
23+
query = """
24+
// -------------------
25+
// find Attribute nodes with multiple identical edges to AttributeValue nodes with the same value
26+
// -------------------
27+
MATCH (a:Attribute)-[e:HAS_VALUE]->(av:AttributeValue)
28+
WITH a, e.branch AS branch, e.branch_level AS branch_level, e.status AS status, e.from AS from, e.to AS to,
29+
av.value AS attr_val, av.is_default AS attr_default, COUNT(*) AS num_duplicate_edges
30+
WHERE num_duplicate_edges > 1
31+
// -------------------
32+
// get the the one AttributeValue we want to use
33+
// -------------------
34+
WITH DISTINCT a, branch, branch_level, status, from, to, attr_val, attr_default
35+
WITH attr_val, attr_default, collect([a, branch, branch_level, status, from, to]) AS details_list
36+
CALL {
37+
WITH attr_val, attr_default
38+
MATCH (av:AttributeValue {value: attr_val, is_default: attr_default})
39+
RETURN av AS the_one_av
40+
ORDER by %(id_func)s(av) ASC
41+
LIMIT 1
42+
}
43+
UNWIND details_list AS details_item
44+
WITH attr_val, attr_default, the_one_av,
45+
details_item[0] AS a, details_item[1] AS branch, details_item[2] AS branch_level,
46+
details_item[3] AS status, details_item[4] AS from, details_item[5] AS to
47+
// -------------------
48+
// get/create the one edge to keep
49+
// -------------------
50+
CREATE (a)-[fresh_e:HAS_VALUE {branch: branch, branch_level: branch_level, status: status, from: from}]->(the_one_av)
51+
SET fresh_e.to = to
52+
WITH a, branch, status, from, to, attr_val, attr_default, %(id_func)s(fresh_e) AS e_id_to_keep
53+
// -------------------
54+
// get the identical edges for a given set of Attribute node, edge properties, AttributeValue.value
55+
// -------------------
56+
CALL {
57+
// -------------------
58+
// delete the duplicate edges a given set of Attribute node, edge properties, AttributeValue.value
59+
// -------------------
60+
WITH a, branch, status, from, to, attr_val, attr_default, e_id_to_keep
61+
MATCH (a)-[e:HAS_VALUE]->(av:AttributeValue {value: attr_val, is_default: attr_default})
62+
WHERE %(id_func)s(e) <> e_id_to_keep
63+
AND e.branch = branch AND e.status = status AND e.from = from
64+
AND (e.to = to OR (e.to IS NULL AND to IS NULL))
65+
DELETE e
66+
}
67+
// -------------------
68+
// delete any orphaned AttributeValue nodes
69+
// -------------------
70+
WITH NULL AS nothing
71+
LIMIT 1
72+
MATCH (orphaned_av:AttributeValue)
73+
WHERE NOT exists((orphaned_av)-[]-())
74+
DELETE orphaned_av
75+
""" % {"id_func": db.get_id_function_name()}
76+
self.add_to_query(query)
77+
78+
79+
class DeleteDuplicateBooleanEdgesQuery(Query):
80+
name = "delete_duplicate_booleans_edges"
81+
type = QueryType.WRITE
82+
insert_return = False
83+
edge_type: DatabaseEdgeType | None = None
84+
85+
async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> None: # noqa: ARG002
86+
if not self.edge_type:
87+
raise RuntimeError("edge_type is required for this query")
88+
query = """
89+
// -------------------
90+
// find Attribute nodes with multiple identical edges to Boolean nodes
91+
// -------------------
92+
MATCH (a:Attribute)-[e:%(edge_type)s]->(b)
93+
WITH a, e.branch AS branch, e.branch_level AS branch_level, e.status AS status, e.from AS from, e.to AS to, b, COUNT(*) AS num_duplicate_edges
94+
WHERE num_duplicate_edges > 1
95+
// -------------------
96+
// get the identical edges for a given set of Attribute node, edge properties, Boolean
97+
// -------------------
98+
WITH DISTINCT a, branch, branch_level, status, from, to, b
99+
CREATE (a)-[fresh_e:%(edge_type)s {branch: branch, branch_level: branch_level, status: status, from: from}]->(b)
100+
SET fresh_e.to = to
101+
WITH a, branch, status, from, to, b, %(id_func)s(fresh_e) AS e_id_to_keep
102+
CALL {
103+
WITH a, branch, status, from, to, b, e_id_to_keep
104+
MATCH (a)-[e:%(edge_type)s]->(b)
105+
WHERE %(id_func)s(e) <> e_id_to_keep
106+
AND e.branch = branch AND e.status = status AND e.from = from
107+
AND (e.to = to OR (e.to IS NULL AND to IS NULL))
108+
DELETE e
109+
}
110+
""" % {"edge_type": self.edge_type.value, "id_func": db.get_id_function_name()}
111+
self.add_to_query(query)
112+
113+
114+
class DeleteDuplicateIsVisibleEdgesQuery(DeleteDuplicateBooleanEdgesQuery):
115+
name = "delete_duplicate_is_visible_edges"
116+
type = QueryType.WRITE
117+
insert_return = False
118+
edge_type = DatabaseEdgeType.IS_VISIBLE
119+
120+
121+
class DeleteDuplicateIsProtectedEdgesQuery(DeleteDuplicateBooleanEdgesQuery):
122+
name = "delete_duplicate_is_protected_edges"
123+
type = QueryType.WRITE
124+
insert_return = False
125+
edge_type = DatabaseEdgeType.IS_PROTECTED
126+
127+
128+
class Migration020(GraphMigration):
129+
"""
130+
1. Find duplicate edges. These can be duplicated if multiple AttributeValue nodes with the same value exist b/c of concurrent
131+
database updates.
132+
a. (a:Attribute)-[e:HAS_VALUE]->(av:AttributeValue)
133+
grouped by (a, e.branch, e.from, e.to, e.status, av.value, av.is_default) to determine the number of duplicates.
134+
b. (a:Attribute)-[e:HAS_VALUE]->(b:Boolean)
135+
grouped by (a, e.branch, e.from, e.status, b) to determine the number of duplicates.
136+
2. For a given set of duplicate edges
137+
a. delete all of the duplicate edges
138+
b. merge one edge with the properties of the deleted edges
139+
3. If there are any orphaned AttributeValue nodes after these changes, then delete them
140+
141+
This migration does not account for consolidating duplicated AttributeValue nodes because more might be created
142+
in the future due to concurrent database updates. A migration to consolidate duplicated AttributeValue nodes
143+
should be run when we find a way to stop duplicate AttributeValue nodes from being created
144+
"""
145+
146+
name: str = "020_delete_duplicate_edges"
147+
minimum_version: int = 19
148+
queries: Sequence[type[Query]] = [
149+
DeleteDuplicateHasValueEdgesQuery,
150+
DeleteDuplicateIsVisibleEdgesQuery,
151+
DeleteDuplicateIsProtectedEdgesQuery,
152+
]
153+
154+
async def execute(self, db: InfrahubDatabase) -> MigrationResult:
155+
# skip the transaction b/c it will run out of memory on a large database
156+
return await self.do_execute(db=db)
157+
158+
async def validate_migration(self, db: InfrahubDatabase) -> MigrationResult: # noqa: ARG002
159+
result = MigrationResult()
160+
return result

backend/infrahub/core/migrations/graph/m020_add_generate_template_attr.py renamed to backend/infrahub/core/migrations/graph/m021_add_generate_template_attr.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@
1515
from infrahub.database import InfrahubDatabase
1616

1717

18-
class Migration020(InternalSchemaMigration):
19-
name: str = "020_add_generate_template_attr"
18+
class Migration021(InternalSchemaMigration):
19+
name: str = "021_add_generate_template_attr"
2020
minimum_version: int = 19
2121

2222
@classmethod

backend/infrahub/core/migrations/shared.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -120,15 +120,17 @@ async def validate_migration(self, db: InfrahubDatabase) -> MigrationResult:
120120

121121
async def execute(self, db: InfrahubDatabase) -> MigrationResult:
122122
async with db.start_transaction() as ts:
123-
result = MigrationResult()
123+
return await self.do_execute(db=ts)
124124

125-
for migration_query in self.queries:
126-
try:
127-
query = await migration_query.init(db=ts)
128-
await query.execute(db=ts)
129-
except Exception as exc:
130-
result.errors.append(str(exc))
131-
return result
125+
async def do_execute(self, db: InfrahubDatabase) -> MigrationResult:
126+
result = MigrationResult()
127+
for migration_query in self.queries:
128+
try:
129+
query = await migration_query.init(db=db)
130+
await query.execute(db=db)
131+
except Exception as exc:
132+
result.errors.append(str(exc))
133+
return result
132134

133135
return result
134136

backend/infrahub/core/node/constraints/grouped_uniqueness.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -177,12 +177,25 @@ async def _check_one_schema(
177177
await self._check_results(updated_node=node, path_groups=path_groups, query_results=query.get_results())
178178

179179
async def check(self, node: Node, at: Optional[Timestamp] = None, filters: Optional[list[str]] = None) -> None:
180+
def _frozen_constraints(schema: MainSchemaTypes) -> frozenset[frozenset[str]]:
181+
if not schema.uniqueness_constraints:
182+
return frozenset()
183+
return frozenset(frozenset(uc) for uc in schema.uniqueness_constraints)
184+
180185
node_schema = node.get_schema()
181-
schemas_to_check: list[MainSchemaTypes] = [node_schema]
186+
include_node_schema = True
187+
frozen_node_constraints = _frozen_constraints(node_schema)
188+
schemas_to_check: list[MainSchemaTypes] = []
182189
if node_schema.inherit_from:
183190
for parent_schema_name in node_schema.inherit_from:
184191
parent_schema = self.schema_branch.get(name=parent_schema_name, duplicate=False)
185-
if parent_schema.uniqueness_constraints:
186-
schemas_to_check.append(parent_schema)
192+
if not parent_schema.uniqueness_constraints:
193+
continue
194+
schemas_to_check.append(parent_schema)
195+
frozen_parent_constraints = _frozen_constraints(parent_schema)
196+
if frozen_node_constraints <= frozen_parent_constraints:
197+
include_node_schema = False
198+
if include_node_schema:
199+
schemas_to_check.append(node_schema)
187200
for schema in schemas_to_check:
188201
await self._check_one_schema(node=node, node_schema=schema, at=at, filters=filters)

backend/infrahub/core/query/attribute.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ async def query_init(self, db: InfrahubDatabase, **kwargs: dict[str, Any]) -> No
6666
query = """
6767
MATCH (a:Attribute { uuid: $attr_uuid })
6868
MERGE (av:%(labels)s { %(props)s } )
69+
WITH av, a
70+
LIMIT 1
6971
CREATE (a)-[r:%(rel_label)s { branch: $branch, branch_level: $branch_level, status: "active", from: $at }]->(av)
7072
""" % {"rel_label": self.attr._rel_to_value_label, "labels": ":".join(labels), "props": ", ".join(prop_list)}
7173

0 commit comments

Comments
 (0)