Repository: medtagger/MedTagger

Issue:
Race condition may result in double conversion to PNG
## Expected Behavior

Conversion to PNG should happen only once.

## Actual Behavior

Due to a race condition, MedTagger can convert Scan many times.

## Steps to Reproduce the Problem

  1. Upload Scan.
  2. Be lucky :)

## Additional comment

That may happen if multiple workers will check for number of uploaded Slices. If it's equal to the number of declared Slices (during empty Scan creation), MedTagger will start conversion.

Example (bota processes are executed in the same time):
```
# Process A:                           # Process B:
...                                    ...
StoreScanInDatabase();                 ...
...                                    StoreScanInDatabase();
...                                    ...
...                                    if (ShouldBeConvertedToPNG()) {
...                                        ConvertToPNG();
...                                    }
...                                    ...
if (ShouldBeConvertedToPNG()) {        ...
    ConvertToPNG();                    ...
}                                      ...
...                                    ...
```

You can try to increase number of workers during tests to increase probability of such race.



Discussion:

Resolving patch:
diff --git a/backend/medtagger/database/models.py b/backend/medtagger/database/models.py
--- a/backend/medtagger/database/models.py
+++ b/backend/medtagger/database/models.py
@@ -120,16 +120,6 @@ def __repr__(self) -> str:
         """Return string representation for Scan."""
         return '<{}: {}: {}: {}>'.format(self.__class__.__name__, self.id, self.category.key, self.owner)
 
-    @property
-    def stored_slices(self) -> List['Slice']:
-        """Return all Slices which were already stored."""
-        with db_session() as session:
-            query = session.query(Slice)
-            query = query.filter(Slice.scan_id == self.id)
-            query = query.filter(Slice.status.in_(  # type: ignore  # "SliceStatus" has no attribute "in_"
-                [SliceStatus.STORED, SliceStatus.PROCESSED]))
-            return query.all()
-
     def add_slice(self, orientation: SliceOrientation = SliceOrientation.Z) -> 'Slice':
         """Add new slice into this Scan.
 
diff --git a/backend/medtagger/repositories/scans.py b/backend/medtagger/repositories/scans.py
--- a/backend/medtagger/repositories/scans.py
+++ b/backend/medtagger/repositories/scans.py
@@ -4,8 +4,8 @@
 from sqlalchemy.sql.expression import func
 
 from medtagger.database import db_session
-from medtagger.database.models import ScanCategory, Scan, User, Label
-from medtagger.definitions import ScanStatus
+from medtagger.database.models import ScanCategory, Scan, Slice, User, Label
+from medtagger.definitions import ScanStatus, SliceStatus
 from medtagger.types import ScanID
 
 
@@ -69,3 +69,23 @@ def reduce_number_of_declared_slices(scan_id: ScanID) -> None:
             query = session.query(Scan)
             query = query.filter(Scan.id == scan_id)
             query.update({"declared_number_of_slices": (Scan.declared_number_of_slices - 1)})
+
+    @staticmethod
+    def try_to_mark_scan_as_stored(scan_id: ScanID) -> bool:
+        """Mark Scan as STORED only if all Slices were STORED.
+
+        :param scan_id: ID of a Scan which should be tried to mark as STORED
+        :return: boolean information if Scan was marked or not
+        """
+        with db_session() as session:
+            stored_slices_subquery = session.query(func.count(Slice.id).label('count'))
+            stored_slices_subquery = stored_slices_subquery.filter(Slice.scan_id == scan_id)
+            stored_slices_subquery = stored_slices_subquery.filter(Slice.status == SliceStatus.STORED)
+            stored_slices_subquery = stored_slices_subquery.subquery()
+
+            query = session.query(Scan)
+            query = query.filter(Scan.id == scan_id)
+            query = query.filter(Scan.status != ScanStatus.STORED)
+            query = query.filter(Scan.declared_number_of_slices == stored_slices_subquery.c.count)
+            updated = query.update({"status": (ScanStatus.STORED)}, synchronize_session=False)
+            return bool(updated)
diff --git a/backend/medtagger/workers/storage.py b/backend/medtagger/workers/storage.py
--- a/backend/medtagger/workers/storage.py
+++ b/backend/medtagger/workers/storage.py
@@ -5,7 +5,7 @@
 import SimpleITK as sitk
 from celery.utils.log import get_task_logger
 
-from medtagger.definitions import DicomTags, ScanStatus, SliceStatus
+from medtagger.definitions import DicomTags, SliceStatus
 from medtagger.types import ScanID, SliceID, SlicePosition, SliceLocation
 from medtagger.workers import celery_app
 from medtagger.workers.conversion import convert_scan_to_png
@@ -45,6 +45,7 @@ def parse_dicom_and_update_slice(slice_id: SliceID) -> None:
         logger.error('User sent a file that is not a DICOM.')
         SlicesRepository.delete_slice_by_id(_slice.id)
         ScansRepository.reduce_number_of_declared_slices(_slice.scan_id)
+        os.unlink(temp_file.name)
         trigger_scan_conversion_if_needed(_slice.scan_id)
         return
 
@@ -56,15 +57,11 @@ def parse_dicom_and_update_slice(slice_id: SliceID) -> None:
     _slice.update_status(SliceStatus.STORED)
     logger.info('"%s" updated.', _slice)
 
-    # Run conversion to PNG if this is the latest uploaded Slice
     trigger_scan_conversion_if_needed(_slice.scan_id)
 
 
 def trigger_scan_conversion_if_needed(scan_id: ScanID) -> None:
-    """Run conversion if all Slices for given Scan were uploaded."""
-    scan = ScansRepository.get_scan_by_id(scan_id)
-    logger.debug('Stored %s Slices. Waiting for %s Slices.', len(scan.stored_slices), scan.declared_number_of_slices)
-    if scan.declared_number_of_slices == len(scan.stored_slices):
-        logger.debug('All Slices uploaded for %s! Running conversion...', scan)
-        scan.update_status(ScanStatus.STORED)
-        convert_scan_to_png.delay(scan.id)
+    """Mark Scan as STORED and trigger conversion to PNG if this is the latest uploaded Slice."""
+    if ScansRepository.try_to_mark_scan_as_stored(scan_id):
+        logger.debug('All Slices uploaded for Scan ID=%s! Running conversion...', scan_id)
+        convert_scan_to_png.delay(scan_id)


Test patch: