Repository: comic/grand-challenge.org

Issue:
`create_algorithm_jobs_for_archive` timesout
Related to #1863, creating algorithm jobs for archives now takes too long and it times out. Doesn't look like its db limited from the RDS logs, but is making a lot of queries. https://sentry.io/organizations/grand-challenge/issues/2280927696/?project=303639&query=is%3Aunresolved


Discussion:
Transaction logs here, lots of similar queries https://sentry.io/organizations/grand-challenge/performance/django-backend:9c943d5db7b447cd8f1ed54d9ec85fa1/?end=2021-06-21T15%3A30%3A00&project=303639&query=transaction.duration%3A%3C15m+event.type%3Atransaction&start=2021-06-20T20%3A51%3A12&transaction=grandchallenge.algorithms.tasks.create_algorithm_jobs_for_archive&unselectedSeries=p100%28%29

Resolving patch:
diff --git a/app/grandchallenge/algorithms/models.py b/app/grandchallenge/algorithms/models.py
--- a/app/grandchallenge/algorithms/models.py
+++ b/app/grandchallenge/algorithms/models.py
@@ -440,8 +440,6 @@ def container(self):
         return self.algorithm_image
 
     def get_path_and_value(self, inp):
-        if inp.file:
-            return [(inp.interface.relative_path, inp.file)]
         if inp.image:
             return [
                 (
@@ -453,7 +451,10 @@ def get_path_and_value(self, inp):
                 )
                 for im_file in inp.image.files.all()
             ]
-        return [(inp.interface.relative_path, inp.value)]
+        elif inp.file:
+            return [(inp.interface.relative_path, inp.file)]
+        else:
+            return [(inp.interface.relative_path, inp.value)]
 
     @property
     def input_files(self):
diff --git a/app/grandchallenge/algorithms/tasks.py b/app/grandchallenge/algorithms/tasks.py
--- a/app/grandchallenge/algorithms/tasks.py
+++ b/app/grandchallenge/algorithms/tasks.py
@@ -3,6 +3,7 @@
 from django.conf import settings
 from django.contrib.sites.models import Site
 from django.core.mail import send_mail
+from django.db.models import Count, Q
 from django.db.transaction import on_commit
 
 from grandchallenge.algorithms.models import (
@@ -146,9 +147,21 @@ def create_algorithm_jobs_for_session(
         kwargs={"session_pk": session.pk}, immutable=True
     )
 
+    default_input_interface = ComponentInterface.objects.get(
+        slug=DEFAULT_INPUT_INTERFACE_SLUG
+    )
+    civ_sets = [
+        {
+            ComponentInterfaceValue.objects.create(
+                interface=default_input_interface, image=image
+            )
+        }
+        for image in session.image_set.all()
+    ]
+
     execute_jobs(
         algorithm_image=algorithm_image,
-        images=session.image_set.all(),
+        civ_sets=civ_sets,
         creator=session.creator,
         extra_viewer_groups=groups,
         linked_task=linked_task,
@@ -181,7 +194,12 @@ def create_algorithm_jobs_for_archive(
         for algorithm in algorithms:
             execute_jobs(
                 algorithm_image=algorithm.latest_ready_image,
-                archive_items=archive_items,
+                civ_sets=[
+                    {*ai.values.all()}
+                    for ai in archive_items.prefetch_related(
+                        "values__interface"
+                    )
+                ],
                 creator=None,
                 extra_viewer_groups=archive_groups,
                 # NOTE: no emails in case the logs leak data
@@ -210,7 +228,12 @@ def create_algorithm_jobs_for_evaluation(*, evaluation_pk):
 
     execute_jobs(
         algorithm_image=evaluation.submission.algorithm_image,
-        archive_items=evaluation.submission.phase.archive.items.all(),
+        civ_sets=[
+            {*ai.values.all()}
+            for ai in evaluation.submission.phase.archive.items.prefetch_related(
+                "values__interface"
+            )
+        ],
         creator=None,
         extra_viewer_groups=groups,
         linked_task=linked_task,
@@ -220,16 +243,14 @@ def create_algorithm_jobs_for_evaluation(*, evaluation_pk):
 def execute_jobs(
     *,
     algorithm_image,
-    images=None,
-    archive_items=None,
+    civ_sets,
     creator=None,
     extra_viewer_groups=None,
     linked_task=None,
 ):
     jobs = create_algorithm_jobs(
         algorithm_image=algorithm_image,
-        images=images,
-        archive_items=archive_items,
+        civ_sets=civ_sets,
         creator=creator,
         extra_viewer_groups=extra_viewer_groups,
     )
@@ -244,129 +265,94 @@ def execute_jobs(
         on_commit(workflow.apply_async)
 
 
-def create_algorithm_job_with_inputs(
-    *, algorithm_image, inputs, creator=None, extra_viewer_groups=None
+def create_algorithm_jobs(
+    *, algorithm_image, civ_sets, creator=None, extra_viewer_groups=None,
 ):
-    if not algorithm_image:
-        return None
-
-    if creator:
-        if (
-            not algorithm_image.algorithm.is_editor(creator)
-            and algorithm_image.algorithm.credits_per_job > 0
-        ):
-            r = remaining_jobs(
-                creator=creator, algorithm_image=algorithm_image
-            )
-            if r <= 0:
-                # no more credits to start job, should not happen as
-                # this is handled in the UI already
-                return None
-
-    job = Job.objects.create(creator=creator, algorithm_image=algorithm_image)
-    job.inputs.set(
-        [ComponentInterfaceValue.objects.get(pk=pk) for pk in inputs]
+    civ_sets = filter_civs_for_algorithm(
+        civ_sets=civ_sets, algorithm_image=algorithm_image
     )
 
-    if extra_viewer_groups is not None:
-        job.viewer_groups.add(*extra_viewer_groups)
-
-    return job
-
+    if (
+        creator
+        and not algorithm_image.algorithm.is_editor(creator)
+        and algorithm_image.algorithm.credits_per_job > 0
+    ):
+        n_jobs = remaining_jobs(
+            creator=creator, algorithm_image=algorithm_image
+        )
+        if n_jobs > 0:
+            civ_sets = civ_sets[:n_jobs]
+        else:
+            # Out of credits
+            return []
 
-def create_algorithm_jobs(  # noqa: C901
-    *,
-    algorithm_image,
-    images=None,
-    archive_items=None,
-    creator=None,
-    extra_viewer_groups=None,
-):
-    default_input_interface = ComponentInterface.objects.get(
-        slug=DEFAULT_INPUT_INTERFACE_SLUG
-    )
     jobs = []
-
-    if not images:
-        images = []
-
-    if not archive_items:
-        archive_items = []
-
-    if not algorithm_image:
-        return jobs
-
-    input_interfaces = algorithm_image.algorithm.inputs.all()
-
-    if creator:
-        if (
-            not algorithm_image.algorithm.is_editor(creator)
-            and algorithm_image.algorithm.credits_per_job > 0
-        ):
-            images = images[
-                : remaining_jobs(
-                    creator=creator, algorithm_image=algorithm_image
-                )
-            ]
-            archive_items = archive_items[
-                : remaining_jobs(
-                    creator=creator, algorithm_image=algorithm_image
-                )
-            ]
-
-    for archive_item in archive_items:
-        job_inputs = archive_item.values.filter(interface__in=input_interfaces)
-
-        # Check if a Job with identical inputs already exists
-        skip = False
-        for job in Job.objects.filter(
-            inputs__interface__in=input_interfaces,
-            algorithm_image=algorithm_image,
-            creator=creator,
-        ):
-            if list(
-                job.inputs.order_by("pk").values("value", "image", "file")
-            ) == list(
-                job_inputs.order_by("pk").values("value", "image", "file")
-            ):
-                skip = True
-                break
-        if skip:
-            continue
+    for civ_set in civ_sets:
         j = Job.objects.create(
             creator=creator, algorithm_image=algorithm_image
         )
-        j.inputs.set(job_inputs)
+        j.inputs.set(civ_set)
 
         if extra_viewer_groups is not None:
             j.viewer_groups.add(*extra_viewer_groups)
 
         jobs.append(j)
 
-    for image in images:
-        if not ComponentInterfaceValue.objects.filter(
-            interface=default_input_interface,
-            image=image,
-            algorithms_jobs_as_input__algorithm_image=algorithm_image,
-            algorithms_jobs_as_input__creator=creator,
-        ).exists():
-            j = Job.objects.create(
-                creator=creator, algorithm_image=algorithm_image
-            )
-            j.inputs.set(
-                [
-                    ComponentInterfaceValue.objects.create(
-                        interface=default_input_interface, image=image
-                    )
-                ]
+    return jobs
+
+
+def filter_civs_for_algorithm(*, civ_sets, algorithm_image):
+    """
+    Removes sets of civs that are invalid for new jobs
+
+    Parameters
+    ----------
+    civ_sets
+        Iterable of sets of ComponentInterfaceValues that are candidate for
+        new Jobs
+    algorithm_image
+        The algorithm image to use for new job
+
+    Returns
+    -------
+    Filtered set of ComponentInterfaceValues
+    """
+    input_interfaces = {*algorithm_image.algorithm.inputs.all()}
+
+    existing_jobs = {
+        frozenset(j.inputs.all())
+        for j in Job.objects.filter(algorithm_image=algorithm_image)
+        .annotate(
+            inputs_match_count=Count(
+                "inputs",
+                filter=Q(
+                    inputs__in={civ for civ_set in civ_sets for civ in civ_set}
+                ),
             )
+        )
+        .filter(inputs_match_count=len(input_interfaces))
+        .prefetch_related("inputs")
+    }
+
+    valid_job_inputs = []
+
+    for civ_set in civ_sets:
+        # Check interfaces are complete
+        civ_interfaces = {civ.interface for civ in civ_set}
+        if input_interfaces.issubset(civ_interfaces):
+            valid_input = {
+                civ for civ in civ_set if civ.interface in input_interfaces
+            }
+        else:
+            continue
 
-            if extra_viewer_groups is not None:
-                j.viewer_groups.add(*extra_viewer_groups)
+        # Check job has not been run
+        if frozenset(valid_input) in existing_jobs:
+            continue
 
-            jobs.append(j)
+        valid_job_inputs.append(valid_input)
 
-    return jobs
+    return valid_job_inputs
 
 
 def remaining_jobs(*, creator, algorithm_image):


Test patch:
diff --git a/app/tests/algorithms_tests/resources/docker/run_algorithm.py b/app/tests/algorithms_tests/resources/docker/run_algorithm.py
--- a/app/tests/algorithms_tests/resources/docker/run_algorithm.py
+++ b/app/tests/algorithms_tests/resources/docker/run_algorithm.py
@@ -1,6 +1,7 @@
 import json
 import os
 import shutil
+from pathlib import Path
 from typing import Union
 from warnings import warn
 
@@ -17,7 +18,8 @@ def write_to_file(path: str, res: Union[dict, str]):
     # What the user uploads will be placed directly in /input/, but the admin
     # is free to determine the file type. The only limitation is that this will
     # be a single file.
-    input_file = "/input/input_file.tif"
+
+    input_file = next(Path("/input/").glob("*.tif"))
     os.makedirs("/output/images")
     shutil.copyfile(input_file, "/output/images/output.tif")
 
diff --git a/app/tests/algorithms_tests/test_permissions.py b/app/tests/algorithms_tests/test_permissions.py
--- a/app/tests/algorithms_tests/test_permissions.py
+++ b/app/tests/algorithms_tests/test_permissions.py
@@ -304,7 +304,9 @@ def test_job_permissions_for_archive(self):
         im = ImageFactory()
         s.image_set.set([im])
 
-        civ = ComponentInterfaceValueFactory(image=im)
+        civ = ComponentInterfaceValueFactory(
+            image=im, interface=ai.algorithm.inputs.get()
+        )
         archive_item = ArchiveItemFactory(archive=archive)
         with capture_on_commit_callbacks(execute=True):
             archive_item.values.add(civ)
@@ -348,7 +350,9 @@ def test_job_permissions_for_challenge(self):
         im = ImageFactory()
         s.image_set.set([im])
 
-        civ = ComponentInterfaceValueFactory(image=im)
+        civ = ComponentInterfaceValueFactory(
+            image=im, interface=ai.algorithm.inputs.get()
+        )
         archive_item = ArchiveItemFactory(archive=archive)
         with capture_on_commit_callbacks(execute=True):
             archive_item.values.add(civ)
diff --git a/app/tests/algorithms_tests/test_tasks.py b/app/tests/algorithms_tests/test_tasks.py
--- a/app/tests/algorithms_tests/test_tasks.py
+++ b/app/tests/algorithms_tests/test_tasks.py
@@ -11,6 +11,7 @@
     create_algorithm_jobs,
     execute_algorithm_job_for_inputs,
     execute_jobs,
+    filter_civs_for_algorithm,
     run_algorithm_job_for_inputs,
 )
 from grandchallenge.components.models import (
@@ -45,7 +46,7 @@ def default_input_interface(self):
 
     def test_no_images_does_nothing(self):
         ai = AlgorithmImageFactory()
-        create_algorithm_jobs(algorithm_image=ai, images=[])
+        create_algorithm_jobs(algorithm_image=ai, civ_sets=[])
         assert Job.objects.count() == 0
 
     def test_civ_existing_does_nothing(self):
@@ -63,8 +64,11 @@ def test_civ_existing_does_nothing(self):
     def test_creates_job_correctly(self):
         ai = AlgorithmImageFactory()
         image = ImageFactory()
+        civ = ComponentInterfaceValueFactory(
+            image=image, interface=ai.algorithm.inputs.get()
+        )
         assert Job.objects.count() == 0
-        jobs = create_algorithm_jobs(algorithm_image=ai, images=[image])
+        jobs = create_algorithm_jobs(algorithm_image=ai, civ_sets=[{civ}])
         assert Job.objects.count() == 1
         j = Job.objects.first()
         assert j.algorithm_image == ai
@@ -78,30 +82,42 @@ def test_creates_job_correctly(self):
     def test_is_idempotent(self):
         ai = AlgorithmImageFactory()
         image = ImageFactory()
+        civ = ComponentInterfaceValueFactory(
+            image=image, interface=ai.algorithm.inputs.get()
+        )
         assert Job.objects.count() == 0
-        create_algorithm_jobs(algorithm_image=ai, images=[image])
+        create_algorithm_jobs(algorithm_image=ai, civ_sets=[{civ}])
         assert Job.objects.count() == 1
-        jobs = create_algorithm_jobs(algorithm_image=ai, images=[image])
+        jobs = create_algorithm_jobs(algorithm_image=ai, civ_sets=[{civ}])
         assert Job.objects.count() == 1
         assert len(jobs) == 0
 
     def test_gets_creator_from_session(self):
+        ai = AlgorithmImageFactory()
         riu = RawImageUploadSessionFactory()
         riu.image_set.add(ImageFactory(), ImageFactory())
+        civ_sets = [
+            {
+                ComponentInterfaceValueFactory(
+                    image=image, interface=ai.algorithm.inputs.get()
+                )
+            }
+            for image in riu.image_set.all()
+        ]
         create_algorithm_jobs(
-            algorithm_image=AlgorithmImageFactory(),
-            images=riu.image_set.all(),
-            creator=riu.creator,
+            algorithm_image=ai, civ_sets=civ_sets, creator=riu.creator,
         )
         j = Job.objects.first()
         assert j.creator == riu.creator
 
     def test_extra_viewer_groups(self):
         ai = AlgorithmImageFactory()
-        image = ImageFactory()
+        civ = ComponentInterfaceValueFactory(
+            interface=ai.algorithm.inputs.get()
+        )
         groups = (GroupFactory(), GroupFactory(), GroupFactory())
         jobs = create_algorithm_jobs(
-            algorithm_image=ai, images=[image], extra_viewer_groups=groups
+            algorithm_image=ai, civ_sets=[{civ}], extra_viewer_groups=groups
         )
         for g in groups:
             assert jobs[0].viewer_groups.filter(pk=g.pk).exists()
@@ -123,16 +139,20 @@ def create_upload(upload_creator):
                 ImageFactory(origin=riu),
 
             riu.save()
-            return riu
+
+            interface = algorithm_image.algorithm.inputs.get()
+
+            return [
+                {ComponentInterfaceValueFactory(image=im, interface=interface)}
+                for im in riu.image_set.all()
+            ]
 
         assert Job.objects.count() == 0
 
         # Create an upload session as editor; should not be limited
         upload = create_upload(editor)
         create_algorithm_jobs(
-            algorithm_image=algorithm_image,
-            images=upload.image_set.all(),
-            creator=upload.creator,
+            algorithm_image=algorithm_image, civ_sets=upload, creator=editor,
         )
 
         assert Job.objects.count() == 3
@@ -140,9 +160,7 @@ def create_upload(upload_creator):
         # Create an upload session as user; should be limited
         upload_2 = create_upload(user)
         create_algorithm_jobs(
-            algorithm_image=algorithm_image,
-            images=upload_2.image_set.all(),
-            creator=upload_2.creator,
+            algorithm_image=algorithm_image, civ_sets=upload_2, creator=user,
         )
 
         # An additional 2 jobs should be created (standard nr of credits is 1000
@@ -154,9 +172,7 @@ def create_upload(upload_creator):
 
         # The job that was skipped on the previous run should now be accepted
         create_algorithm_jobs(
-            algorithm_image=algorithm_image,
-            images=upload_2.image_set.all(),
-            creator=upload_2.creator,
+            algorithm_image=algorithm_image, civ_sets=upload_2, creator=user,
         )
         assert Job.objects.count() == 6
 
@@ -165,14 +181,22 @@ class TestCreateJobsWorkflow(TestCase):
     def test_no_jobs_workflow(self):
         ai = AlgorithmImageFactory()
         with capture_on_commit_callbacks() as callbacks:
-            execute_jobs(algorithm_image=ai, images=[])
+            execute_jobs(algorithm_image=ai, civ_sets=[])
         assert len(callbacks) == 0
 
     def test_jobs_workflow(self):
         ai = AlgorithmImageFactory()
         images = [ImageFactory(), ImageFactory()]
+        civ_sets = [
+            {
+                ComponentInterfaceValueFactory(
+                    image=im, interface=ai.algorithm.inputs.get()
+                )
+            }
+            for im in images
+        ]
         with capture_on_commit_callbacks() as callbacks:
-            execute_jobs(algorithm_image=ai, images=images)
+            execute_jobs(algorithm_image=ai, civ_sets=civ_sets)
         assert len(callbacks) == 1
 
 
@@ -196,11 +220,15 @@ def test_algorithm(client, algorithm_image, settings):
 
     # Run the algorithm, it will create a results.json and an output.tif
     image_file = ImageFileFactory(
-        file__from_path=Path(__file__).parent / "resources" / "input_file.tif"
+        file__from_path=Path(__file__).parent / "resources" / "input_file.tif",
+    )
+    civ = ComponentInterfaceValueFactory(
+        image=image_file.image, interface=alg.algorithm.inputs.get(), file=None
     )
+    assert civ.interface.slug == "generic-medical-image"
 
     with capture_on_commit_callbacks(execute=True):
-        execute_jobs(algorithm_image=alg, images=[image_file.image])
+        execute_jobs(algorithm_image=alg, civ_sets=[{civ}])
 
     jobs = Job.objects.filter(algorithm_image=alg).all()
 
@@ -242,9 +270,12 @@ def test_algorithm(client, algorithm_image, settings):
     image_file = ImageFileFactory(
         file__from_path=Path(__file__).parent / "resources" / "input_file.tif"
     )
+    civ = ComponentInterfaceValueFactory(
+        image=image_file.image, interface=alg.algorithm.inputs.get(), file=None
+    )
 
     with capture_on_commit_callbacks(execute=True):
-        execute_jobs(algorithm_image=alg, images=[image_file.image])
+        execute_jobs(algorithm_image=alg, civ_sets=[{civ}])
 
     jobs = Job.objects.filter(
         algorithm_image=alg, inputs__image=image_file.image
@@ -286,9 +317,12 @@ def test_algorithm_with_invalid_output(client, algorithm_image, settings):
     image_file = ImageFileFactory(
         file__from_path=Path(__file__).parent / "resources" / "input_file.tif"
     )
+    civ = ComponentInterfaceValueFactory(
+        image=image_file.image, interface=alg.algorithm.inputs.get(), file=None
+    )
 
     with capture_on_commit_callbacks(execute=True):
-        execute_jobs(algorithm_image=alg, images=[image_file.image])
+        execute_jobs(algorithm_image=alg, civ_sets=[{civ}])
 
     jobs = Job.objects.filter(
         algorithm_image=alg, inputs__image=image_file.image, status=Job.FAILURE
@@ -478,3 +512,89 @@ def test_execute_algorithm_job_for_inputs(
         "Job can't be started, input is missing for interface(s):"
         in job.error_message
     )
+
+
+@pytest.mark.django_db
+class TestJobCreation:
+    def test_unmatched_interface_filter(self):
+        ai = AlgorithmImageFactory()
+        cis = ComponentInterfaceFactory.create_batch(2)
+        ai.algorithm.inputs.set(cis)
+
+        civ_sets = [
+            {},  # No interfaces
+            {
+                ComponentInterfaceValueFactory(interface=cis[0])
+            },  # Missing interface
+            {
+                # OK
+                ComponentInterfaceValueFactory(interface=cis[0]),
+                ComponentInterfaceValueFactory(interface=cis[1]),
+            },
+            {
+                # Unmatched interface
+                ComponentInterfaceValueFactory(interface=cis[0]),
+                ComponentInterfaceValueFactory(
+                    interface=ComponentInterfaceFactory()
+                ),
+            },
+        ]
+
+        filtered_civ_sets = filter_civs_for_algorithm(
+            civ_sets=civ_sets, algorithm_image=ai
+        )
+
+        assert filtered_civ_sets == [civ_sets[2]]
+
+    def test_unmatched_interface_filter_subset(self):
+        ai = AlgorithmImageFactory()
+        cis = ComponentInterfaceFactory.create_batch(2)
+        ai.algorithm.inputs.set(cis)
+
+        civ_sets = [
+            {
+                # Extra interface
+                ComponentInterfaceValueFactory(interface=cis[0]),
+                ComponentInterfaceValueFactory(interface=cis[1]),
+                ComponentInterfaceValueFactory(
+                    interface=ComponentInterfaceFactory()
+                ),
+            },
+        ]
+
+        filtered_civ_sets = filter_civs_for_algorithm(
+            civ_sets=civ_sets, algorithm_image=ai
+        )
+
+        assert len(filtered_civ_sets) == 1
+        assert {civ.interface for civ in filtered_civ_sets[0]} == {*cis}
+
+    def test_existing_jobs(self):
+        ai = AlgorithmImageFactory()
+        cis = ComponentInterfaceFactory.create_batch(2)
+        ai.algorithm.inputs.set(cis)
+
+        civs = [ComponentInterfaceValueFactory(interface=c) for c in cis]
+
+        j = AlgorithmJobFactory(algorithm_image=ai)
+        j.inputs.set(civs)
+
+        civ_sets = [
+            civs,  # Job already exists
+            {
+                # New values
+                ComponentInterfaceValueFactory(interface=cis[0]),
+                ComponentInterfaceValueFactory(interface=cis[1]),
+            },
+            {
+                # Changed values
+                civs[0],
+                ComponentInterfaceValueFactory(interface=cis[1]),
+            },
+        ]
+
+        filtered_civ_sets = filter_civs_for_algorithm(
+            civ_sets=civ_sets, algorithm_image=ai
+        )
+
+        assert filtered_civ_sets == civ_sets[1:]
diff --git a/app/tests/archives_tests/test_forms.py b/app/tests/archives_tests/test_forms.py
--- a/app/tests/archives_tests/test_forms.py
+++ b/app/tests/archives_tests/test_forms.py
@@ -8,7 +8,11 @@
     Archive,
     ArchivePermissionRequest,
 )
-from grandchallenge.components.models import ComponentInterface, InterfaceKind
+from grandchallenge.components.models import (
+    ComponentInterface,
+    ComponentInterfaceValue,
+    InterfaceKind,
+)
 from tests.algorithms_tests.factories import (
     AlgorithmFactory,
     AlgorithmImageFactory,
@@ -232,12 +236,14 @@ def test_archive_item_form(client, settings):
 
     alg = AlgorithmFactory()
     AlgorithmImageFactory(algorithm=alg, ready=True)
-    alg.inputs.add(ci)
+    alg.inputs.set([ci])
     with capture_on_commit_callbacks(execute=True):
         archive.algorithms.add(alg)
 
     assert Job.objects.count() == 1
 
+    civ_count = ComponentInterfaceValue.objects.count()
+
     with capture_on_commit_callbacks(execute=True):
         with capture_on_commit_callbacks(execute=True):
             response = get_view_for_user(
@@ -251,6 +257,8 @@ def test_archive_item_form(client, settings):
             )
 
     assert ai.values.filter(pk=civ.pk).count() == 0
+    # This should created a new CIV as they are immutable
+    assert ComponentInterfaceValue.objects.count() == civ_count + 1
 
     # A new job should have been created, because the value for 'bool'
     # has changed
@@ -268,6 +276,6 @@ def test_archive_item_form(client, settings):
                 user=editor,
             )
 
-    # No new jobs should have been created, because the value for 'bool'
-    # has changed to a value for which a job already exists
-    assert Job.objects.count() == 2
+    # New jobs should be created as there is a new CIV
+    assert Job.objects.count() == 3
+    assert ComponentInterfaceValue.objects.count() == civ_count + 2