From 43be035480883c9c1b5a93147a1b34078d037498 Mon Sep 17 00:00:00 2001 From: Program2113 Date: Tue, 29 Sep 2026 16:33:00 +0530 Subject: [PATCH 1/4] fix(api): resolve duplicate work item identifiers instead of erroring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `GET /api/v1/workspaces//work-items/-/` looked the work item up with a bare `.get()`. `(project, sequence_id)` has no database-level uniqueness constraint — it is kept unique only by the project-scoped advisory lock `Issue.save()` takes while deriving the next sequence — so any write that skips `save()` (a `bulk_create`, a data migration, a restore) can leave a project with two work items sharing an identifier. `.get()` then raised `MultipleObjectsReturned`, which `handle_exception` does not map to a status, so every request for that identifier returned HTTP 500 and the work item became permanently unreachable through this endpoint. Resolve to the most recent match instead, ordering by `-created_at` with `id` as a tie-break so the answer is deterministic, and log a warning naming the identifier and the row returned so the duplicates can be tracked down and cleaned up. `issue_objects` hides soft-deleted, archived, draft and triage rows, so the re-query can legitimately come back empty; that case answers 404. --- apps/api/plane/api/views/issue.py | 36 +++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index 4c88a4103ba..c77f6b2704c 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -4,6 +4,7 @@ # Python imports import json +import logging import uuid import re @@ -161,6 +162,9 @@ from plane.bgtasks.work_item_link_task import crawl_work_item_link_title +logger = logging.getLogger("plane.api") + + def user_has_issue_permission(user_id, project_id, issue=None, allowed_roles=None, allow_creator=True): if allow_creator and issue is not None and user_id == issue.created_by_id: return True @@ -241,16 +245,44 @@ def get(self, request, slug, project_identifier=None, issue_identifier=None): # sequence_id is an integer, so anything else can't be a work item here. if not issue_identifier.isdecimal(): return Response({"error": "Work item not found"}, status=status.HTTP_404_NOT_FOUND) - issue = Issue.issue_objects.annotate( + issue_queryset = Issue.issue_objects.annotate( sub_issues_count=Issue.issue_objects.filter(parent=OuterRef("id")) .order_by() .annotate(count=Func(F("id"), function="Count")) .values("count") - ).get( + ).filter( workspace__slug=slug, project__identifier=project_identifier, sequence_id=issue_identifier, ) + + try: + issue = issue_queryset.get() + except Issue.MultipleObjectsReturned: + # `(project, sequence_id)` carries no database-level uniqueness + # constraint. It is kept unique only by the project-scoped advisory + # lock `Issue.save()` takes while deriving the next sequence, so any + # path that writes rows without going through `save()` — a + # `bulk_create`, a data migration, a restore — can leave a project + # with two work items sharing an identifier. Resolve to the most + # recent match so the lookup stays deterministic instead of letting + # `MultipleObjectsReturned` escape as a 500, and log the anomaly so + # the duplicate rows can be found and cleaned up. + issue = issue_queryset.order_by("-created_at", "id").first() + if issue is None: + # `issue_objects` hides soft-deleted, archived, draft and triage + # rows, so a concurrent write can drop every match between the two + # queries. Answer as though it never matched. + return Response({"error": "Work item not found"}, status=status.HTTP_404_NOT_FOUND) + logger.warning( + "Multiple work items match identifier %s-%s in workspace '%s'; " + "returning the most recent match (id=%s). Investigate the duplicate sequence_id.", + project_identifier, + issue_identifier, + slug, + issue.id, + ) + return Response( IssueSerializer(issue, fields=self.fields, expand=self.expand).data, status=status.HTTP_200_OK, From 2670493b3e2fc45a456230bdbf6f923767f3ea96 Mon Sep 17 00:00:00 2001 From: Program2113 Date: Tue, 29 Sep 2026 16:33:12 +0530 Subject: [PATCH 2/4] test(api): cover work item lookup by project identifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pins the duplicate-`sequence_id` behaviour: a collision resolves to the most recent match and is logged, rather than surfacing as HTTP 500. Also covers the unique and unknown-identifier paths, and asserts the resolution is stable when the duplicates share a `created_at`, which is what the `id` tie-break is for. Duplicates are forced with a queryset `update()`, since `Issue.save()` derives `sequence_id` under an advisory lock and `created_at` is `auto_now_add` — the same way the writes that bypass `save()` produce the collision in production. The module resets the API key throttle bucket between tests: every test authenticates with the same key and `ApiKeyRateThrottle` buckets by key, so without it a full-suite run exhausts the limit and these tests see 429 instead of the status under test. --- .../api/test_work_item_identifier_lookup.py | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py diff --git a/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py b/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py new file mode 100644 index 00000000000..a042d9b5760 --- /dev/null +++ b/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py @@ -0,0 +1,169 @@ +# Copyright (c) 2023-present Plane Software, Inc. and contributors +# SPDX-License-Identifier: AGPL-3.0-only +# See the LICENSE file for details. + +"""Work-item lookup by ``-`` tolerates duplicates. + +``(project, sequence_id)`` has no unique constraint. It is kept unique only by the +project-scoped advisory lock ``Issue.save()`` takes while deriving the next sequence, +so any write that skips ``save()`` can leave a project with two work items sharing an +identifier. The lookup used a bare ``.get()``, so those projects answered +``MultipleObjectsReturned`` — an exception ``handle_exception`` does not map, making +every request for that identifier an HTTP 500. +""" + +import logging +from datetime import timedelta + +import pytest +from django.core.cache import cache +from django.utils import timezone +from rest_framework import status + +from plane.db.models import Issue, Project, ProjectMember, State + + +@pytest.fixture(autouse=True) +def reset_api_key_throttle(api_token): + """Keep these tests independent of how many ran before them. + + Every test authenticates with the same API key, and ``ApiKeyRateThrottle`` + buckets by key, so a full-suite run can exhaust the limit before reaching this + module and answer 429 instead of the status under test. + """ + cache.delete(f"api_key:{api_token.token}") + + +@pytest.fixture +def project(db, workspace, create_user): + """A project with the requesting user as an active member.""" + project = Project.objects.create( + name="Test Project", + identifier="TP", + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create(project=project, member=create_user, role=20, is_active=True) + return project + + +@pytest.fixture +def state(db, workspace, project): + return State.objects.create( + name="Todo", + project=project, + workspace=workspace, + group="backlog", + default=True, + ) + + +def _create_issue(workspace, project, state, user, name): + return Issue.objects.create( + name=name, + workspace=workspace, + project=project, + state=state, + created_by=user, + ) + + +def _force_sequence_id(issue, sequence_id, created_at): + """Collide two work items on one ``sequence_id``. + + ``Issue.save()`` derives ``sequence_id`` under an advisory lock and ``created_at`` + is ``auto_now_add``, so neither can be set through the model. A queryset + ``update()`` writes both columns directly, which is exactly how the paths that + skip ``save()`` produce the duplicate in the first place. + """ + Issue.objects.filter(pk=issue.pk).update(sequence_id=sequence_id, created_at=created_at) + issue.refresh_from_db() + return issue + + +@pytest.fixture +def duplicate_identifier_issues(db, workspace, project, state, create_user): + """Two work items in one project sharing ``sequence_id`` 1, ``newer`` created last.""" + now = timezone.now() + older = _create_issue(workspace, project, state, create_user, "Older") + newer = _create_issue(workspace, project, state, create_user, "Newer") + older = _force_sequence_id(older, 1, now - timedelta(hours=1)) + newer = _force_sequence_id(newer, 1, now) + return older, newer + + +def _identifier_urls(workspace, project, sequence_id): + """Both routes bound to the endpoint: the deprecated one and its replacement.""" + return [ + f"/api/v1/workspaces/{workspace.slug}/issues/{project.identifier}-{sequence_id}/", + f"/api/v1/workspaces/{workspace.slug}/work-items/{project.identifier}-{sequence_id}/", + ] + + +@pytest.mark.contract +class TestWorkItemIdentifierLookup: + @pytest.mark.django_db + def test_unique_identifier_returns_the_work_item(self, api_key_client, workspace, project, state, create_user): + """Baseline: one match still resolves to that work item.""" + issue = _create_issue(workspace, project, state, create_user, "Only") + + for url in _identifier_urls(workspace, project, issue.sequence_id): + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(issue.id) + + @pytest.mark.django_db + def test_duplicate_identifier_resolves_to_most_recent( + self, api_key_client, workspace, project, duplicate_identifier_issues + ): + """Duplicates used to raise MultipleObjectsReturned → 500.""" + _older, newer = duplicate_identifier_issues + + for url in _identifier_urls(workspace, project, 1): + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(newer.id) + + @pytest.mark.django_db + def test_duplicate_identifier_is_logged_as_a_warning( + self, api_key_client, caplog, workspace, project, duplicate_identifier_issues + ): + """The anomaly is reported so the duplicate rows can be cleaned up.""" + _older, newer = duplicate_identifier_issues + url = f"/api/v1/workspaces/{workspace.slug}/work-items/{project.identifier}-1/" + + with caplog.at_level(logging.WARNING, logger="plane.api"): + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + warnings = [record.getMessage() for record in caplog.records if record.levelno == logging.WARNING] + assert any(f"{project.identifier}-1" in message and str(newer.id) in message for message in warnings) + + @pytest.mark.django_db + def test_duplicate_identifier_resolves_consistently_on_identical_timestamps( + self, api_key_client, workspace, project, state, create_user + ): + """``created_at`` alone is not a total order, so ``id`` breaks the tie.""" + same_instant = timezone.now() + first = _create_issue(workspace, project, state, create_user, "First") + second = _create_issue(workspace, project, state, create_user, "Second") + _force_sequence_id(first, 1, same_instant) + _force_sequence_id(second, 1, same_instant) + + url = f"/api/v1/workspaces/{workspace.slug}/work-items/{project.identifier}-1/" + responses = [api_key_client.get(url) for _ in range(3)] + + assert [response.status_code for response in responses] == [status.HTTP_200_OK] * 3 + assert len({str(response.data["id"]) for response in responses}) == 1 + + @pytest.mark.django_db + def test_unknown_identifier_still_returns_404(self, api_key_client, workspace, project, state, create_user): + """A sequence that matches nothing is unaffected by the duplicate handling.""" + _create_issue(workspace, project, state, create_user, "Only") + + for url in _identifier_urls(workspace, project, 9999): + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND From c44946605b70808304eadec9799618d09ebffabd Mon Sep 17 00:00:00 2001 From: Program2113 Date: Tue, 29 Sep 2026 18:01:53 +0530 Subject: [PATCH 3/4] fix(api): scope identifier lookup to the live project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A project identifier is only unique among live projects — the constraint is `project_unique_identifier_workspace_when_deleted_at_null` — so once a project is soft-deleted its identifier can be handed to a new project in the same workspace. Soft deletion cascades to the work items through an async task, and `Issue.issue_objects` filters the work item's own `deleted_at` but not its project's, so until that task lands both projects' rows answer to the same identifier. The lookup filtered on `project__identifier` alone, so it matched across that boundary. Collapsing those matches to "most recent" is not resolving a duplicate sequence within a project, it is choosing between two different projects, and `-created_at` can just as easily select the stale project's row as the live one's. Filter on the live project so the identifier resolves to the single project it actually names. Duplicate resolution then only ever applies within one project, which is the case it was written for. This also stops the endpoint serving work items belonging to a soft-deleted project during the cascade window; those now answer 404. That matches the handling of archived projects, which `IssueManager` already excludes. --- apps/api/plane/api/views/issue.py | 26 ++++++--- .../api/test_work_item_identifier_lookup.py | 57 +++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index c77f6b2704c..a409186f084 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -253,21 +253,29 @@ def get(self, request, slug, project_identifier=None, issue_identifier=None): ).filter( workspace__slug=slug, project__identifier=project_identifier, + # A project identifier is only unique among live projects + # (`project_unique_identifier_workspace_when_deleted_at_null`), so it + # can be reused once the project holding it is soft-deleted. Soft + # deletion cascades to the work items asynchronously, so until that + # task runs both projects' rows answer to the same identifier. Scope + # the lookup to the live project, the one the identifier actually + # names, rather than resolving across a project boundary. + project__deleted_at__isnull=True, sequence_id=issue_identifier, ) try: issue = issue_queryset.get() except Issue.MultipleObjectsReturned: - # `(project, sequence_id)` carries no database-level uniqueness - # constraint. It is kept unique only by the project-scoped advisory - # lock `Issue.save()` takes while deriving the next sequence, so any - # path that writes rows without going through `save()` — a - # `bulk_create`, a data migration, a restore — can leave a project - # with two work items sharing an identifier. Resolve to the most - # recent match so the lookup stays deterministic instead of letting - # `MultipleObjectsReturned` escape as a 500, and log the anomaly so - # the duplicate rows can be found and cleaned up. + # Within that one project, `(project, sequence_id)` still carries no + # database-level uniqueness constraint. It is kept unique only by the + # project-scoped advisory lock `Issue.save()` takes while deriving the + # next sequence, so any path that writes rows without going through + # `save()` — a `bulk_create`, a data migration, a restore — can leave a + # project with two work items sharing an identifier. Resolve to the + # most recent match so the lookup stays deterministic instead of + # letting `MultipleObjectsReturned` escape as a 500, and log the + # anomaly so the duplicate rows can be found and cleaned up. issue = issue_queryset.order_by("-created_at", "id").first() if issue is None: # `issue_objects` hides soft-deleted, archived, draft and triage diff --git a/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py b/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py index a042d9b5760..88597b94b20 100644 --- a/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py +++ b/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py @@ -10,6 +10,10 @@ identifier. The lookup used a bare ``.get()``, so those projects answered ``MultipleObjectsReturned`` — an exception ``handle_exception`` does not map, making every request for that identifier an HTTP 500. + +A project identifier is also only unique among live projects, so it can be reused after +the project holding it is soft-deleted. Collapsing duplicates must not reach across that +boundary and answer with a different project's work item. """ import logging @@ -49,6 +53,7 @@ def project(db, workspace, create_user): @pytest.fixture def state(db, workspace, project): + """A default backlog state, required to create a work item.""" return State.objects.create( name="Todo", project=project, @@ -59,6 +64,7 @@ def state(db, workspace, project): def _create_issue(workspace, project, state, user, name): + """Create a work item, letting ``save()`` assign the next ``sequence_id``.""" return Issue.objects.create( name=name, workspace=workspace, @@ -158,6 +164,57 @@ def test_duplicate_identifier_resolves_consistently_on_identical_timestamps( assert [response.status_code for response in responses] == [status.HTTP_200_OK] * 3 assert len({str(response.data["id"]) for response in responses}) == 1 + @pytest.mark.django_db + def test_identifier_reused_after_soft_delete_resolves_to_the_live_project( + self, api_key_client, workspace, project, state, create_user + ): + """A reused identifier must not resolve into the soft-deleted project. + + The soft-deleted project's work item is made the more recent of the two, so + ordering alone would pick it. + """ + live_issue = _create_issue(workspace, project, state, create_user, "Live") + _force_sequence_id(live_issue, 1, timezone.now() - timedelta(hours=1)) + + # Soft-delete the project; the cascade to its work items runs asynchronously, + # so its rows stay visible in the meantime. + Project.objects.filter(pk=project.pk).update(deleted_at=timezone.now()) + stale_issue, stale_project = live_issue, project + + # The unique constraint only covers live projects, so the identifier is free. + revived = Project.objects.create( + name="Revived Project", + identifier=stale_project.identifier, + workspace=workspace, + created_by=create_user, + ) + ProjectMember.objects.create(project=revived, member=create_user, role=20, is_active=True) + revived_state = State.objects.create( + name="Todo", project=revived, workspace=workspace, group="backlog", default=True + ) + revived_issue = _create_issue(workspace, revived, revived_state, create_user, "Revived") + _force_sequence_id(stale_issue, 1, timezone.now()) + _force_sequence_id(revived_issue, 1, timezone.now() - timedelta(hours=2)) + + for url in _identifier_urls(workspace, revived, 1): + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_200_OK + assert str(response.data["id"]) == str(revived_issue.id) + + @pytest.mark.django_db + def test_work_item_of_a_soft_deleted_project_is_not_reachable( + self, api_key_client, workspace, project, state, create_user + ): + """With no live project behind the identifier there is nothing to return.""" + _create_issue(workspace, project, state, create_user, "Orphan") + Project.objects.filter(pk=project.pk).update(deleted_at=timezone.now()) + + for url in _identifier_urls(workspace, project, 1): + response = api_key_client.get(url) + + assert response.status_code == status.HTTP_404_NOT_FOUND + @pytest.mark.django_db def test_unknown_identifier_still_returns_404(self, api_key_client, workspace, project, state, create_user): """A sequence that matches nothing is unaffected by the duplicate handling.""" From f600b68d5a5853eb34ba05e120a5ed032fa5e575 Mon Sep 17 00:00:00 2001 From: Program2113 Date: Tue, 29 Sep 2026 18:30:29 +0530 Subject: [PATCH 4/4] style(api): trim comments on the identifier lookup The explanatory comments had grown longer than the code they describe. Keep the reason, drop the retelling. --- apps/api/plane/api/views/issue.py | 25 +++--------- .../api/test_work_item_identifier_lookup.py | 38 +++++-------------- 2 files changed, 15 insertions(+), 48 deletions(-) diff --git a/apps/api/plane/api/views/issue.py b/apps/api/plane/api/views/issue.py index a409186f084..7629f1a01c2 100644 --- a/apps/api/plane/api/views/issue.py +++ b/apps/api/plane/api/views/issue.py @@ -253,13 +253,8 @@ def get(self, request, slug, project_identifier=None, issue_identifier=None): ).filter( workspace__slug=slug, project__identifier=project_identifier, - # A project identifier is only unique among live projects - # (`project_unique_identifier_workspace_when_deleted_at_null`), so it - # can be reused once the project holding it is soft-deleted. Soft - # deletion cascades to the work items asynchronously, so until that - # task runs both projects' rows answer to the same identifier. Scope - # the lookup to the live project, the one the identifier actually - # names, rather than resolving across a project boundary. + # Identifiers are only unique among live projects, so a soft-deleted + # project still answers to one its replacement now owns. project__deleted_at__isnull=True, sequence_id=issue_identifier, ) @@ -267,20 +262,12 @@ def get(self, request, slug, project_identifier=None, issue_identifier=None): try: issue = issue_queryset.get() except Issue.MultipleObjectsReturned: - # Within that one project, `(project, sequence_id)` still carries no - # database-level uniqueness constraint. It is kept unique only by the - # project-scoped advisory lock `Issue.save()` takes while deriving the - # next sequence, so any path that writes rows without going through - # `save()` — a `bulk_create`, a data migration, a restore — can leave a - # project with two work items sharing an identifier. Resolve to the - # most recent match so the lookup stays deterministic instead of - # letting `MultipleObjectsReturned` escape as a 500, and log the - # anomaly so the duplicate rows can be found and cleaned up. + # `(project, sequence_id)` has no unique constraint, only the advisory + # lock in `Issue.save()`, so writes that skip `save()` can collide. + # Pick deterministically instead of letting this escape as a 500. issue = issue_queryset.order_by("-created_at", "id").first() if issue is None: - # `issue_objects` hides soft-deleted, archived, draft and triage - # rows, so a concurrent write can drop every match between the two - # queries. Answer as though it never matched. + # A concurrent write can hide every match between the two queries. return Response({"error": "Work item not found"}, status=status.HTTP_404_NOT_FOUND) logger.warning( "Multiple work items match identifier %s-%s in workspace '%s'; " diff --git a/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py b/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py index 88597b94b20..4826644bbca 100644 --- a/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py +++ b/apps/api/plane/tests/contract/api/test_work_item_identifier_lookup.py @@ -2,18 +2,10 @@ # SPDX-License-Identifier: AGPL-3.0-only # See the LICENSE file for details. -"""Work-item lookup by ``-`` tolerates duplicates. - -``(project, sequence_id)`` has no unique constraint. It is kept unique only by the -project-scoped advisory lock ``Issue.save()`` takes while deriving the next sequence, -so any write that skips ``save()`` can leave a project with two work items sharing an -identifier. The lookup used a bare ``.get()``, so those projects answered -``MultipleObjectsReturned`` — an exception ``handle_exception`` does not map, making -every request for that identifier an HTTP 500. - -A project identifier is also only unique among live projects, so it can be reused after -the project holding it is soft-deleted. Collapsing duplicates must not reach across that -boundary and answer with a different project's work item. +"""Work-item lookup by ``-``. + +Neither ``(project, sequence_id)`` nor a project identifier is unique on its own, so a +bare ``.get()`` here either 500s or answers with the wrong project's work item. """ import logging @@ -29,12 +21,7 @@ @pytest.fixture(autouse=True) def reset_api_key_throttle(api_token): - """Keep these tests independent of how many ran before them. - - Every test authenticates with the same API key, and ``ApiKeyRateThrottle`` - buckets by key, so a full-suite run can exhaust the limit before reaching this - module and answer 429 instead of the status under test. - """ + """All tests share one API key, so a full-suite run can 429 this module.""" cache.delete(f"api_key:{api_token.token}") @@ -77,10 +64,8 @@ def _create_issue(workspace, project, state, user, name): def _force_sequence_id(issue, sequence_id, created_at): """Collide two work items on one ``sequence_id``. - ``Issue.save()`` derives ``sequence_id`` under an advisory lock and ``created_at`` - is ``auto_now_add``, so neither can be set through the model. A queryset - ``update()`` writes both columns directly, which is exactly how the paths that - skip ``save()`` produce the duplicate in the first place. + ``save()`` assigns ``sequence_id`` and ``created_at`` is ``auto_now_add``, so an + ``update()`` is the only way in — the same way the real duplicates get written. """ Issue.objects.filter(pk=issue.pk).update(sequence_id=sequence_id, created_at=created_at) issue.refresh_from_db() @@ -168,16 +153,11 @@ def test_duplicate_identifier_resolves_consistently_on_identical_timestamps( def test_identifier_reused_after_soft_delete_resolves_to_the_live_project( self, api_key_client, workspace, project, state, create_user ): - """A reused identifier must not resolve into the soft-deleted project. - - The soft-deleted project's work item is made the more recent of the two, so - ordering alone would pick it. - """ + """The stale work item is the newer one, so ordering alone would pick it.""" live_issue = _create_issue(workspace, project, state, create_user, "Live") _force_sequence_id(live_issue, 1, timezone.now() - timedelta(hours=1)) - # Soft-delete the project; the cascade to its work items runs asynchronously, - # so its rows stay visible in the meantime. + # The cascade to its work items is async, so they stay visible meanwhile. Project.objects.filter(pk=project.pk).update(deleted_at=timezone.now()) stale_issue, stale_project = live_issue, project