|
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 |
2 | 6 |
|
3 | 7 | 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 |
5 | 9 |
|
6 | 10 | from application.core.models import ( |
7 | 11 | Branch, |
|
13 | 17 | from application.core.types import Status |
14 | 18 | from application.notifications.services.tasks import handle_task_exception |
15 | 19 |
|
| 20 | +logger = logging.getLogger("secobserve.core") |
16 | 21 |
|
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") |
18 | 46 | def find_potential_duplicates(product: Product, branch: Optional[Branch], service: Optional[Service]) -> None: |
19 | 47 | 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) |
25 | 52 |
|
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 | + ) |
28 | 64 | except Exception as e: |
29 | 65 | handle_task_exception(e) |
30 | 66 |
|
31 | 67 |
|
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 | + ) |
66 | 180 |
|
67 | 181 |
|
68 | 182 | def set_potential_duplicate_both_ways(observation: Observation) -> None: |
|
0 commit comments