Skip to content

Commit a86fb57

Browse files
committed
chore: optimise calculation of potential duplicates
1 parent 6c8a7d3 commit a86fb57

2 files changed

Lines changed: 353 additions & 44 deletions

File tree

‎backend/application/core/services/potential_duplicates.py‎

Lines changed: 158 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
from typing import Optional
1+
import logging
2+
from collections import defaultdict
3+
from collections.abc import Iterator
4+
from itertools import batched, combinations
5+
from typing import NamedTuple, Optional
26

37
from django.db.models.query import QuerySet
4-
from huey.contrib.djhuey import on_commit_task
8+
from huey.contrib.djhuey import lock_task, on_commit_task
59

610
from application.core.models import (
711
Branch,
@@ -13,56 +17,166 @@
1317
from application.core.types import Status
1418
from application.notifications.services.tasks import handle_task_exception
1519

20+
logger = logging.getLogger("secobserve.core")
1621

17-
@on_commit_task()
22+
BULK_BATCH_SIZE = 1000
23+
24+
25+
class DuplicateCandidate(NamedTuple):
26+
"""An active observation, reduced to the fields that are needed to match duplicates."""
27+
28+
id: int
29+
title: str
30+
origin_component_name: str
31+
origin_source_file: str
32+
origin_source_line_start: Optional[int]
33+
scanner: str
34+
35+
36+
# Type of the potential duplicate per pair of observation ids, lower id first
37+
DuplicateTypes = dict[tuple[int, int], str]
38+
39+
40+
# The lock serializes all recalculations, so that concurrent imports cannot write
41+
# potential duplicates for the same observations at the same time. If the lock cannot be
42+
# acquired, Huey retries the task later. The retries have to be high enough to bridge
43+
# the recalculations of the other tasks waiting for the lock.
44+
@on_commit_task(retries=5, retry_delay=60)
45+
@lock_task("find_potential_duplicates_lock")
1846
def find_potential_duplicates(product: Product, branch: Optional[Branch], service: Optional[Service]) -> None:
1947
try:
20-
observations = Observation.objects.filter(
21-
product=product,
22-
branch=branch,
23-
origin_service=service,
24-
)
48+
observations = Observation.objects.filter(product=product, branch=branch, origin_service=service)
49+
50+
candidates = _get_duplicate_candidates(observations)
51+
duplicate_types = _match_duplicate_candidates(candidates)
2552

26-
for observation in observations:
27-
_handle_observation(observation, observations)
53+
_write_potential_duplicates(observations, duplicate_types)
54+
_set_has_potential_duplicates(observations, product, duplicate_types)
55+
56+
logger.debug(
57+
"Potential duplicates for product %s / branch %s / service %s: %s candidates, %s pairs",
58+
product.pk,
59+
branch.pk if branch else None,
60+
service.pk if service else None,
61+
len(candidates),
62+
len(duplicate_types),
63+
)
2864
except Exception as e:
2965
handle_task_exception(e)
3066

3167

32-
def _handle_observation(observation: Observation, observations: QuerySet[Observation]) -> None:
33-
Potential_Duplicate.objects.filter(observation=observation).delete()
34-
initial_has_potential_duplicates = observation.has_potential_duplicates
35-
observation.has_potential_duplicates = False
36-
if observation.current_status in Status.STATUS_ACTIVE:
37-
for potential_duplicate_observation in observations:
38-
if (
39-
observation != potential_duplicate_observation
40-
and potential_duplicate_observation.current_status in Status.STATUS_ACTIVE
41-
):
42-
potential_duplicate_type = None
43-
if (
44-
observation.origin_component_name
45-
and potential_duplicate_observation.origin_component_name
46-
and observation.title == potential_duplicate_observation.title
47-
):
48-
potential_duplicate_type = Potential_Duplicate.POTENTIAL_DUPLICATE_TYPE_COMPONENT
49-
if (
50-
observation.origin_source_file
51-
and observation.origin_source_line_start
52-
and observation.origin_source_file == potential_duplicate_observation.origin_source_file
53-
and observation.origin_source_line_start == potential_duplicate_observation.origin_source_line_start
54-
and observation.scanner != potential_duplicate_observation.scanner
55-
):
56-
potential_duplicate_type = Potential_Duplicate.POTENTIAL_DUPLICATE_TYPE_SOURCE
57-
if potential_duplicate_type:
58-
Potential_Duplicate.objects.update_or_create(
59-
observation=observation,
60-
potential_duplicate_observation=potential_duplicate_observation,
61-
defaults={"type": potential_duplicate_type},
62-
)
63-
observation.has_potential_duplicates = True
64-
if observation.has_potential_duplicates != initial_has_potential_duplicates:
65-
observation.save()
68+
def _get_duplicate_candidates(observations: QuerySet[Observation]) -> list[DuplicateCandidate]:
69+
# Only active observations can be duplicates of each other, and only the fields that
70+
# are needed for matching are read, to keep this cheap for products with many
71+
# observations.
72+
rows = observations.filter(current_status__in=Status.STATUS_ACTIVE).values(
73+
"id",
74+
"title",
75+
"origin_component_name",
76+
"origin_source_file",
77+
"origin_source_line_start",
78+
"scanner",
79+
)
80+
return [DuplicateCandidate(**row) for row in rows.iterator(chunk_size=BULK_BATCH_SIZE)]
81+
82+
83+
def _match_duplicate_candidates(candidates: list[DuplicateCandidate]) -> DuplicateTypes:
84+
duplicate_types: DuplicateTypes = {}
85+
86+
for id_pair in _match_by_component(candidates):
87+
duplicate_types[id_pair] = Potential_Duplicate.POTENTIAL_DUPLICATE_TYPE_COMPONENT
88+
89+
# Source is matched last, because its type takes precedence over Component
90+
for id_pair in _match_by_source(candidates):
91+
duplicate_types[id_pair] = Potential_Duplicate.POTENTIAL_DUPLICATE_TYPE_SOURCE
92+
93+
return duplicate_types
94+
95+
96+
def _match_by_component(candidates: list[DuplicateCandidate]) -> Iterator[tuple[int, int]]:
97+
"""Observations with the same title, if both of them have a component."""
98+
candidates_by_title: dict[str, list[DuplicateCandidate]] = defaultdict(list)
99+
for candidate in candidates:
100+
if candidate.origin_component_name:
101+
candidates_by_title[candidate.title].append(candidate)
102+
103+
for candidates_with_same_title in candidates_by_title.values():
104+
for candidate_1, candidate_2 in combinations(candidates_with_same_title, 2):
105+
yield _get_id_pair(candidate_1, candidate_2)
106+
107+
108+
def _match_by_source(candidates: list[DuplicateCandidate]) -> Iterator[tuple[int, int]]:
109+
"""Observations from different scanners for the same line in the same source file."""
110+
candidates_by_source: dict[tuple[str, int], list[DuplicateCandidate]] = defaultdict(list)
111+
for candidate in candidates:
112+
if candidate.origin_source_file and candidate.origin_source_line_start is not None:
113+
source = (candidate.origin_source_file, candidate.origin_source_line_start)
114+
candidates_by_source[source].append(candidate)
115+
116+
for candidates_with_same_source in candidates_by_source.values():
117+
for candidate_1, candidate_2 in combinations(candidates_with_same_source, 2):
118+
if candidate_1.scanner != candidate_2.scanner:
119+
yield _get_id_pair(candidate_1, candidate_2)
120+
121+
122+
def _get_id_pair(candidate_1: DuplicateCandidate, candidate_2: DuplicateCandidate) -> tuple[int, int]:
123+
# The lower id always comes first, so that both matching rules describe the same pair
124+
# of observations with the same key
125+
return (min(candidate_1.id, candidate_2.id), max(candidate_1.id, candidate_2.id))
126+
127+
128+
def _write_potential_duplicates(observations: QuerySet[Observation], duplicate_types: DuplicateTypes) -> None:
129+
Potential_Duplicate.objects.filter(observation__in=observations).delete()
130+
131+
potential_duplicates = []
132+
for (observation_id_1, observation_id_2), duplicate_type in duplicate_types.items():
133+
# Every pair is stored in both directions
134+
potential_duplicates.append(
135+
Potential_Duplicate(
136+
observation_id=observation_id_1,
137+
potential_duplicate_observation_id=observation_id_2,
138+
type=duplicate_type,
139+
)
140+
)
141+
potential_duplicates.append(
142+
Potential_Duplicate(
143+
observation_id=observation_id_2,
144+
potential_duplicate_observation_id=observation_id_1,
145+
type=duplicate_type,
146+
)
147+
)
148+
149+
Potential_Duplicate.objects.bulk_create(potential_duplicates, batch_size=BULK_BATCH_SIZE)
150+
151+
152+
def _set_has_potential_duplicates(
153+
observations: QuerySet[Observation], product: Product, duplicate_types: DuplicateTypes
154+
) -> None:
155+
observation_ids_with_duplicates: set[int] = set()
156+
for observation_id_1, observation_id_2 in duplicate_types:
157+
observation_ids_with_duplicates.add(observation_id_1)
158+
observation_ids_with_duplicates.add(observation_id_2)
159+
160+
# This also contains observations that are not active anymore, their flag has to be
161+
# reset as well.
162+
flagged_observation_ids = set(observations.filter(has_potential_duplicates=True).values_list("id", flat=True))
163+
164+
_update_has_potential_duplicates(observation_ids_with_duplicates - flagged_observation_ids, True)
165+
_update_has_potential_duplicates(flagged_observation_ids - observation_ids_with_duplicates, False)
166+
167+
# The observations are updated without save(), so the product flag that would be set
168+
# in set_product_flags() has to be set here. As there, it is only ever set to True,
169+
# housekeeping resets it.
170+
if observation_ids_with_duplicates:
171+
Product.objects.filter(pk=product.pk, has_potential_duplicates=False).update(has_potential_duplicates=True)
172+
173+
174+
def _update_has_potential_duplicates(observation_ids: set[int], has_potential_duplicates: bool) -> None:
175+
# Batched to stay below the parameter limits of the databases
176+
for observation_ids_batch in batched(observation_ids, BULK_BATCH_SIZE):
177+
Observation.objects.filter(id__in=observation_ids_batch).update(
178+
has_potential_duplicates=has_potential_duplicates
179+
)
66180

67181

68182
def set_potential_duplicate_both_ways(observation: Observation) -> None:

0 commit comments

Comments
 (0)