From 7a62ce45e42527d71cdf85a500b5d89c3cb6cd8f Mon Sep 17 00:00:00 2001 From: Harsh Vardhan Date: Thu, 13 Aug 2026 18:58:11 +0000 Subject: [PATCH 1/2] fix(cycles): wrap progress_snapshot save and bulk_update in transaction.atomic transfer_cycle_issues performs two sequential writes without a transaction: saving progress_snapshot on the old cycle, then bulk-updating CycleIssue rows. A crash between the two writes leaves snapshot committed but issues unmoved. Fix: wrap both writes in django.db.transaction.atomic so both succeed or both roll back atomically. Fixes makeplane/plane#9599 Signed-off-by: harsh4vardhan --- apps/api/plane/utils/cycle_transfer_issues.py | 53 ++++++++++--------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py index 3c012d84b7b..2dde6514236 100644 --- a/apps/api/plane/utils/cycle_transfer_issues.py +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -16,7 +16,7 @@ Value, When, ) -from django.db import models +from django.db import models, transaction from django.db.models.functions import Cast, Concat from django.utils import timezone @@ -429,33 +429,36 @@ def transfer_cycle_issues( } ), } - current_cycle.save(update_fields=["progress_snapshot"]) + # Wrap snapshot save and issue bulk-update in a single atomic transaction so that + # a process kill or DB error after the snapshot commit cannot leave issues unmoved. + with transaction.atomic(): + current_cycle.save(update_fields=["progress_snapshot"]) - # Get issues to transfer (only incomplete issues) - cycle_issues = CycleIssue.objects.filter( - cycle_id=cycle_id, - project_id=project_id, - workspace__slug=slug, - issue__archived_at__isnull=True, - issue__is_draft=False, - issue__state__group__in=["backlog", "unstarted", "started"], - ) - - updated_cycles = [] - update_cycle_issue_activity = [] - for cycle_issue in cycle_issues: - cycle_issue.cycle_id = new_cycle_id - updated_cycles.append(cycle_issue) - update_cycle_issue_activity.append( - { - "old_cycle_id": str(cycle_id), - "new_cycle_id": str(new_cycle_id), - "issue_id": str(cycle_issue.issue_id), - } + # Get issues to transfer (only incomplete issues) + cycle_issues = CycleIssue.objects.filter( + cycle_id=cycle_id, + project_id=project_id, + workspace__slug=slug, + issue__archived_at__isnull=True, + issue__is_draft=False, + issue__state__group__in=["backlog", "unstarted", "started"], ) - # Bulk update cycle issues - cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) + updated_cycles = [] + update_cycle_issue_activity = [] + for cycle_issue in cycle_issues: + cycle_issue.cycle_id = new_cycle_id + updated_cycles.append(cycle_issue) + update_cycle_issue_activity.append( + { + "old_cycle_id": str(cycle_id), + "new_cycle_id": str(new_cycle_id), + "issue_id": str(cycle_issue.issue_id), + } + ) + + # Bulk update cycle issues + cycle_issues = CycleIssue.objects.bulk_update(updated_cycles, ["cycle_id"], batch_size=100) # Capture Issue Activity issue_activity.delay( From 31f0ce6d79a377506842c9387b726238c3935585 Mon Sep 17 00:00:00 2001 From: Harsh Vardhan Date: Thu, 13 Aug 2026 19:17:11 +0000 Subject: [PATCH 2/2] fix(cycles): reject transfer from draft or active cycles before entering transaction CodeRabbit review: validate that the source cycle has actually completed before saving its progress snapshot or moving its issues. A cycle with end_date=None (draft/no deadline) or end_date >= now() (still active) has not finished, so taking a progress snapshot would be meaningless and bulk-reassigning its issues could corrupt an ongoing sprint. Add an early-return guard immediately after the old_cycle is loaded, before the transaction.atomic() block. Signed-off-by: harsh4vardhan --- apps/api/plane/utils/cycle_transfer_issues.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/api/plane/utils/cycle_transfer_issues.py b/apps/api/plane/utils/cycle_transfer_issues.py index 2dde6514236..32f06e6cfcc 100644 --- a/apps/api/plane/utils/cycle_transfer_issues.py +++ b/apps/api/plane/utils/cycle_transfer_issues.py @@ -147,6 +147,16 @@ def transfer_cycle_issues( "error": "Source cycle not found", } + # Only transfer issues from cycles that have actually ended. + # Draft cycles (end_date is None) or active cycles (end_date >= now) + # have not completed, so a progress snapshot would be meaningless and + # bulk-moving their issues could corrupt ongoing sprint work. + if old_cycle.end_date is None or old_cycle.end_date >= timezone.now(): + return { + "success": False, + "error": "Issues can only be transferred from a completed cycle", + } + # Check if project uses estimates estimate_type = Project.objects.filter( workspace__slug=slug,