You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/04/29 15:34:57 UTC

(tooling-trusted-releases) branch sbp updated: Make starting a vote serial and atomic

This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new ecc34ec8 Make starting a vote serial and atomic
ecc34ec8 is described below

commit ecc34ec8b562b355fd193251fda6f4520b1ecb64
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Wed Apr 29 16:34:22 2026 +0100

    Make starting a vote serial and atomic
---
 atr/api/__init__.py                |  18 +-
 atr/db/interaction.py              |  19 +++
 atr/get/resolve.py                 |   2 +-
 atr/get/vote.py                    |   2 +-
 atr/models/args.py                 |   1 +
 atr/post/manual.py                 |   5 +-
 atr/storage/writers/release.py     | 145 +++++++++++-----
 atr/storage/writers/vote.py        | 332 +++++++++++++++++++++----------------
 atr/tasks/vote.py                  |  10 +-
 tests/unit/test_vote_recipients.py | 100 ++++++++++-
 tests/unit/test_vote_resolution.py |  13 +-
 tests/unit/test_vote_serials.py    | 250 ++++++++++++++++++++++++++++
 12 files changed, 697 insertions(+), 200 deletions(-)

diff --git a/atr/api/__init__.py b/atr/api/__init__.py
index 85c0702a..c068f9be 100644
--- a/atr/api/__init__.py
+++ b/atr/api/__init__.py
@@ -1573,15 +1573,17 @@ async def vote_start(
     asf_uid = _jwt_asf_uid()
 
     try:
-        async with storage.write(asf_uid) as write:
-            wacp = await write.as_project_committee_participant(data.project)
-            async with db.session() as read_data:
-                committee = await read_data.committee(key=wacp.committee_key).demand(
-                    storage.AccessError(f"Committee not found: {wacp.committee_key}")
-                )
+        async with db.session() as db_data:
+            project = await db_data.project(key=str(data.project), _committee=True).demand(
+                storage.AccessError(f"Project not found: {data.project}")
+            )
+            committee = project.committee
+            if committee is None:
+                raise storage.AccessError("No committee found for project - Invalid state")
+        async with storage.write_as_committee_participant(committee.key, asf_uid) as wacp:
             permitted_recipients = util.permitted_podling_first_round_recipients(
                 asf_uid,
-                wacp.committee_key,
+                committee.key,
                 is_podling=committee.is_podling,
             )
             if data.email_to not in permitted_recipients:
@@ -1635,7 +1637,7 @@ async def vote_tabulate(
             exceptions.NotFound(f"Release {release_key} not found"),
         )
 
-    latest_vote_task = await interaction.release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_current_vote_task(release)
     if latest_vote_task is None:
         raise exceptions.NotFound("No vote task found")
     task_mid = interaction.task_mid_get(latest_vote_task)
diff --git a/atr/db/interaction.py b/atr/db/interaction.py
index 7575b15c..9f634163 100644
--- a/atr/db/interaction.py
+++ b/atr/db/interaction.py
@@ -265,6 +265,25 @@ async def previews(project: sql.Project) -> list[sql.Release]:
     return await releases_by_phase(project, sql.ReleasePhase.RELEASE_PREVIEW)
 
 
+async def release_current_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
+    current_vote_seq = getattr(release, "current_vote_seq", None)
+    if current_vote_seq is None:
+        return await release_latest_vote_task(release, caller_data)
+    via = sql.validate_instrumented_attribute
+    async with db.ensure_session(caller_data) as data:
+        query = (
+            sqlmodel.select(sql.Task)
+            .where(sql.Task.project_key == release.project_key)
+            .where(sql.Task.version_key == release.version)
+            .where(sql.Task.task_type == sql.TaskType.VOTE_INITIATE)
+            .where(sqlalchemy.func.json_extract(sql.Task.task_args, "$.vote_seq") == current_vote_seq)
+            .order_by(via(sql.Task.added).desc())
+            .limit(1)
+        )
+        task = (await data.execute(query)).scalar_one_or_none()
+        return task
+
+
 async def release_latest_vote_task(release: sql.Release, caller_data: db.Session | None = None) -> sql.Task | None:
     """Find the most recent VOTE_INITIATE task for this release."""
     via = sql.validate_instrumented_attribute
diff --git a/atr/get/resolve.py b/atr/get/resolve.py
index b2878c7b..04a9b73e 100644
--- a/atr/get/resolve.py
+++ b/atr/get/resolve.py
@@ -64,7 +64,7 @@ async def selected(  # noqa: C901
     archive_url = None
     fetch_error = None
 
-    latest_vote_task = await interaction.release_latest_vote_task(release)
+    latest_vote_task = await interaction.release_current_vote_task(release)
     if latest_vote_task is not None:
         task_mid = interaction.task_mid_get(latest_vote_task)
         task_recipient = interaction.task_recipient_get(latest_vote_task)
diff --git a/atr/get/vote.py b/atr/get/vote.py
index 4f8da2aa..8cccde4d 100644
--- a/atr/get/vote.py
+++ b/atr/get/vote.py
@@ -76,7 +76,7 @@ async def category_and_release(
         if release.committee is None:
             raise ValueError("Release has no committee")
 
-        latest_vote_task = await interaction.release_latest_vote_task(release, data)
+        latest_vote_task = await interaction.release_current_vote_task(release, data)
         vote_initiator_uid: str | None = None
         if latest_vote_task is not None:
             vote_initiator_uid = latest_vote_task.task_args.get("initiator_id")
diff --git a/atr/models/args.py b/atr/models/args.py
index 0a7713b0..d3ca8045 100644
--- a/atr/models/args.py
+++ b/atr/models/args.py
@@ -95,6 +95,7 @@ class Initiate(schema.Strict):
     initiator_fullname: str = schema.description("Full name of the vote initiator")
     subject: str = schema.description("Subject line for the vote email")
     body: str = schema.description("Body content for the vote email")
+    vote_seq: int | None = None
     email_cc: list[pydantic.EmailStr] = schema.factory(list)
     email_bcc: list[pydantic.EmailStr] = schema.factory(list)
     second_round_email_to: pydantic.EmailStr | None = pydantic.Field(
diff --git a/atr/post/manual.py b/atr/post/manual.py
index 00063864..5b8d51ac 100644
--- a/atr/post/manual.py
+++ b/atr/post/manual.py
@@ -108,11 +108,10 @@ async def start_selected_revision(
                     project_key=str(project_key),
                     version_key=str(version_key),
                 )
-            case (release, _committee):
+            case (release, committee):
                 pass
 
-        async with storage.write(session) as write:
-            wacp = await write.as_project_committee_participant(release.safe_project_key)
+        async with storage.write_as_committee_participant(committee.key, session) as wacp:
             error = await wacp.release.promote_to_candidate(
                 release.safe_key,
                 revision,
diff --git a/atr/storage/writers/release.py b/atr/storage/writers/release.py
index 49048f4a..203d3e43 100644
--- a/atr/storage/writers/release.py
+++ b/atr/storage/writers/release.py
@@ -26,12 +26,14 @@ from typing import TYPE_CHECKING, Any, Final
 
 import aiofiles.os
 import sqlalchemy
+import sqlalchemy.dialects.sqlite as sqlite
 import sqlalchemy.engine as engine
 import sqlmodel
 
 import atr.analysis as analysis
 import atr.config as config
 import atr.db as db
+import atr.db.interaction as interaction
 import atr.hashes as hashes
 import atr.log as log
 import atr.models.api as api
@@ -218,7 +220,11 @@ class CommitteeParticipant(FoundationCommitter):
                 via(sql.RevisionCounter.release_key) == release_key
             )
             await self.__data.execute(counter_delete_stmt)
-            log.info(f"Deleted revision counter for test release: {release_key}")
+            vote_counter_delete_stmt = sqlmodel.delete(sql.VoteCounter).where(
+                via(sql.VoteCounter.release_key) == release_key
+            )
+            await self.__data.execute(vote_counter_delete_stmt)
+            log.info(f"Deleted revision and vote counters for test release: {release_key}")
 
         # Filesystem deletions are more likely to have permission errors than database deletions
         # Therefore we do filesystem deletions first
@@ -449,66 +455,129 @@ class CommitteeParticipant(FoundationCommitter):
         allowed_vote_modes: frozenset[sql.VoteMode],
     ) -> str | None:
         """Promote a release candidate draft to a new phase."""
+        try:
+            await self.__data.begin_immediate()
+            release, vote_seq, vote_mode = await self._start_vote_no_commit(
+                release_key,
+                selected_revision_number,
+                allowed_vote_modes=allowed_vote_modes,
+                promote=True,
+            )
+            await self.__data.commit()
+        except storage.AccessError as e:
+            await self.__data.rollback()
+            return str(e)
+        except Exception:
+            await self.__data.rollback()
+            raise
+
+        self.__write_as.append_to_audit_log(
+            asf_uid=self.__asf_uid,
+            release_key=release.key,
+            selected_revision_number=str(selected_revision_number),
+            vote_seq=vote_seq,
+            vote_mode=vote_mode.value,
+        )
+        return None
+
+    async def _start_vote_no_commit(  # noqa: C901
+        self,
+        release_key: safe.ReleaseKey,
+        selected_revision_number: safe.RevisionNumber,
+        *,
+        allowed_vote_modes: frozenset[sql.VoteMode],
+        promote: bool,
+        expected_podling_thread_id: str | None = None,
+    ) -> tuple[sql.Release, int, sql.VoteMode]:
         release_for_pre_checks = await self.__data.release(
-            key=str(release_key), _project=True, _project_release_policy=True
+            key=str(release_key), _project=True, _committee=True, _project_release_policy=True
         ).demand(storage.AccessError("Release candidate draft not found"))
         project_key = release_for_pre_checks.safe_project_key
         version_key = release_for_pre_checks.safe_version_key
         revision_number = release_for_pre_checks.safe_latest_revision_number
-        vote_mode = release_for_pre_checks.effective_vote_mode
+        if promote:
+            vote_mode = release_for_pre_checks.effective_vote_mode
+        else:
+            vote_mode = release_for_pre_checks.vote_mode
+            if vote_mode is None:
+                raise storage.AccessError("The release state has changed, please refresh and try again")
         if vote_mode not in allowed_vote_modes:
-            return "This release's vote mode does not allow that action"
+            raise storage.AccessError("This release's vote mode does not allow that action")
 
         # Check for ongoing tasks
-        ongoing_tasks = await self.__tasks_ongoing(project_key, version_key, selected_revision_number)
-        if ongoing_tasks > 0:
-            return "All checks must be completed before starting a vote"
+        if promote:
+            ongoing_tasks = await self.__tasks_ongoing(project_key, version_key, selected_revision_number)
+            if ongoing_tasks > 0:
+                raise storage.AccessError("All checks must be completed before starting a vote")
 
         # Verify that it's in the correct phase
-        if release_for_pre_checks.phase != sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT:
-            return "This release is not in the candidate draft phase"
+        expected_phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+        if not promote:
+            expected_phase = sql.ReleasePhase.RELEASE_CANDIDATE
+        if release_for_pre_checks.phase != expected_phase:
+            if promote:
+                raise storage.AccessError("This release is not in the candidate draft phase")
+            raise storage.AccessError("The release state has changed, please refresh and try again")
 
         # Check that the revision number is the latest
         if revision_number != selected_revision_number:
-            return "The selected revision number does not match the latest revision number"
+            raise storage.AccessError("The selected revision number does not match the latest revision number")
+
+        if await interaction.has_blocker_checks(
+            release_for_pre_checks, selected_revision_number, caller_data=self.__data
+        ):
+            raise storage.AccessError(
+                "This release candidate draft has blockers. Please fix the blockers before starting a vote."
+            )
 
         # Check that there is at least one file in the draft
-        file_count = await util.number_of_release_files(release_for_pre_checks)
-        if file_count == 0:
-            return "This candidate draft is empty, containing no files"
+        if promote:
+            file_count = await util.number_of_release_files(release_for_pre_checks)
+            if file_count == 0:
+                raise storage.AccessError("This candidate draft is empty, containing no files")
 
         # Promote it to RELEASE_CANDIDATE
         via = sql.validate_instrumented_attribute
-        stmt = (
-            sqlmodel.update(sql.Release)
-            .where(
-                via(sql.Release.key) == release_for_pre_checks.key,
-                via(sql.Release.phase) == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
-                sql.latest_revision_number_query() == str(selected_revision_number),
-            )
-            .values(
-                phase=sql.ReleasePhase.RELEASE_CANDIDATE,
-                vote_mode=vote_mode,
-                vote_started=datetime.datetime.now(datetime.UTC),
-                vote_resolved=None,
-            )
+        vote_seq = await self.__vote_seq_allocate(release_for_pre_checks.key)
+        values: dict[str, object] = {
+            "vote_started": datetime.datetime.now(datetime.UTC),
+            "vote_resolved": None,
+            "current_vote_seq": vote_seq,
+        }
+        if promote:
+            values["phase"] = sql.ReleasePhase.RELEASE_CANDIDATE
+            values["vote_mode"] = vote_mode
+        stmt = sqlmodel.update(sql.Release).where(
+            via(sql.Release.key) == release_for_pre_checks.key,
+            via(sql.Release.phase) == expected_phase,
+            sql.latest_revision_number_query() == str(selected_revision_number),
         )
-
-        result = await self.__data.execute(stmt)
+        if not promote:
+            if expected_podling_thread_id is None:
+                stmt = stmt.where(via(sql.Release.podling_thread_id).is_(None))
+            else:
+                stmt = stmt.where(via(sql.Release.podling_thread_id) == expected_podling_thread_id)
+        result = await self.__data.execute(stmt.values(**values))
         if not isinstance(result, engine.CursorResult):
             log.error(f"Expected cursor result, got {type(result)}")
-            return "An error occurred while promoting the release candidate"
+            raise storage.AccessError("An error occurred while promoting the release candidate")
         if result.rowcount != 1:
-            await self.__data.rollback()
-            return "A newer revision appeared, please refresh and try again."
-        await self.__data.commit()
-        self.__write_as.append_to_audit_log(
-            asf_uid=self.__asf_uid,
-            release_key=str(release_key),
-            selected_revision_number=str(selected_revision_number),
-            vote_mode=vote_mode.value,
+            raise storage.AccessError("A newer revision appeared, please refresh and try again.")
+        await self.__data.refresh(release_for_pre_checks)
+        return release_for_pre_checks, vote_seq, vote_mode
+
+    async def __vote_seq_allocate(self, release_key: str) -> int:
+        upsert_stmt = (
+            sqlite.insert(sql.VoteCounter)
+            .values(release_key=release_key, last_allocated_number=1)
+            .on_conflict_do_update(
+                index_elements=["release_key"],
+                set_={"last_allocated_number": sqlalchemy.text("last_allocated_number + 1")},
+            )
+            .returning(sqlalchemy.literal_column("last_allocated_number"))
         )
-        return None
+        result = await self.__data.execute(upsert_stmt)
+        return int(result.scalar_one())
 
     async def remove_rc_tags(
         self, project_key: safe.ProjectKey, version_key: safe.VersionKey
diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index 3fac6225..dd4ca44a 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -88,7 +88,7 @@ class CommitteeParticipant(FoundationCommitter):
         is_binding: bool = False,
     ) -> tuple[list[str], str]:
         # Get the email thread
-        latest_vote_task = await interaction.release_latest_vote_task(release)
+        latest_vote_task = await interaction.release_current_vote_task(release)
         if latest_vote_task is None:
             return [], "No vote task found."
         vote_thread_mid = interaction.task_mid_get(latest_vote_task)
@@ -159,85 +159,106 @@ class CommitteeParticipant(FoundationCommitter):
         second_round_email_to: str | None = None,
         expected_vote_mode: sql.VoteMode | None = None,
     ) -> sql.Task:
-        if release is None:
-            release = await self.__data.release(
-                project_key=str(project_key),
-                version=str(version_key),
-                _project=True,
-                _committee=True,
-            ).demand(storage.AccessError("Release not found"))
-        if release.committee is None:
-            raise storage.AccessError("Release has no committee")
-        if permitted_recipients is None:
-            permitted_recipients = util.permitted_podling_first_round_recipients(
-                self.__asf_uid,
-                release.committee.key,
-                is_podling=release.committee.is_podling,
-            )
-        all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
-        for addr in all_addrs:
-            if addr not in permitted_recipients:
-                # This will be checked again by tasks/vote.py for extra safety
-                log.info(f"Invalid mailing list choice: {addr} not in {permitted_recipients}")
-                raise storage.AccessError("Invalid mailing list choice")
-
-        if second_round_email_to is not None:
-            second_round_permitted = util.permitted_podling_second_round_recipients(self.__asf_uid)
-            if second_round_email_to not in second_round_permitted:
-                log.info(
-                    f"Invalid second round mailing list choice: {second_round_email_to} not in {second_round_permitted}"
+        if promote:
+            await self.__data.begin_immediate()
+        try:
+            release_key = sql.release_key(project_key, version_key)
+            if promote is True:
+                # This verifies the state and sets the phase to RELEASE_CANDIDATE
+                allowed_vote_modes = (
+                    frozenset({expected_vote_mode})
+                    if expected_vote_mode is not None
+                    else frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED})
                 )
-                raise storage.AccessError("Invalid second round mailing list choice")
-
-        if await interaction.has_blocker_checks(release, selected_revision_number, caller_data=self.__data):
-            raise storage.AccessError(
-                "This release candidate draft has blockers. Please fix the blockers before starting a vote."
-            )
-
-        if promote is True:
-            # This verifies the state and sets the phase to RELEASE_CANDIDATE
-            allowed_vote_modes = (
-                frozenset({expected_vote_mode})
-                if expected_vote_mode is not None
-                else frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED})
-            )
-            error = await self.__write_as.release.promote_to_candidate(
-                release.safe_key,
-                selected_revision_number,
-                allowed_vote_modes=allowed_vote_modes,
+                release, vote_seq, vote_mode = await self.__write_as.release._start_vote_no_commit(
+                    release_key,
+                    selected_revision_number,
+                    allowed_vote_modes=allowed_vote_modes,
+                    promote=True,
+                )
+            else:
+                if release is None:
+                    release = await self.__data.release(
+                        project_key=str(project_key),
+                        version=str(version_key),
+                        _project=True,
+                        _committee=True,
+                    ).demand(storage.AccessError("Release not found"))
+                release, vote_seq, vote_mode = await self.__write_as.release._start_vote_no_commit(
+                    release.safe_key,
+                    selected_revision_number,
+                    allowed_vote_modes=frozenset({sql.VoteMode.EMAIL, sql.VoteMode.TRUSTED}),
+                    promote=False,
+                    expected_podling_thread_id=release.podling_thread_id,
+                )
+            if release.committee is None:
+                raise storage.AccessError("Release has no committee")
+            if permitted_recipients is None:
+                permitted_recipients = util.permitted_podling_first_round_recipients(
+                    self.__asf_uid,
+                    release.committee.key,
+                    is_podling=release.committee.is_podling,
+                )
+            all_addrs = [email_to] + (email_cc or []) + (email_bcc or [])
+            for addr in all_addrs:
+                if addr not in permitted_recipients:
+                    # This will be checked again by tasks/vote.py for extra safety
+                    log.info(f"Invalid mailing list choice: {addr} not in {permitted_recipients}")
+                    raise storage.AccessError("Invalid mailing list choice")
+
+            if second_round_email_to is not None:
+                second_round_permitted = util.permitted_podling_second_round_recipients(self.__asf_uid)
+                if second_round_email_to not in second_round_permitted:
+                    log.info(
+                        "Invalid second round mailing list choice: "
+                        f"{second_round_email_to} not in {second_round_permitted}"
+                    )
+                    raise storage.AccessError("Invalid second round mailing list choice")
+
+            # TODO: We also need to store the duration of the vote
+            # We can't allow resolution of the vote until the duration has elapsed
+            # But we allow the user to specify in the form
+            # And yet we also have ReleasePolicy.min_hours
+            # Presumably this sets the default, and the form takes precedence?
+            # ReleasePolicy.min_hours can also be 0, though
+
+            # Create a task for vote initiation
+            task = sql.Task(
+                status=sql.TaskStatus.QUEUED,
+                task_type=sql.TaskType.VOTE_INITIATE,
+                task_args=args.Initiate(
+                    release_key=release.key,
+                    email_to=email_to,
+                    vote_duration=vote_duration_choice,
+                    initiator_id=self.__asf_uid,
+                    initiator_fullname=asf_fullname,
+                    subject=subject,
+                    body=body_data,
+                    vote_seq=vote_seq,
+                    email_cc=email_cc or [],
+                    email_bcc=email_bcc or [],
+                    second_round_email_to=second_round_email_to,
+                ).model_dump(),
+                asf_uid=self.__asf_uid,
+                project_key=str(project_key),
+                version_key=str(version_key),
             )
-            if error:
-                raise storage.AccessError(error)
-
-        # TODO: We also need to store the duration of the vote
-        # We can't allow resolution of the vote until the duration has elapsed
-        # But we allow the user to specify in the form
-        # And yet we also have ReleasePolicy.min_hours
-        # Presumably this sets the default, and the form takes precedence?
-        # ReleasePolicy.min_hours can also be 0, though
+            self.__data.add(task)
+            if promote:
+                await self.__data.commit()
+        except Exception:
+            if promote:
+                await self.__data.rollback()
+            raise
 
-        # Create a task for vote initiation
-        task = sql.Task(
-            status=sql.TaskStatus.QUEUED,
-            task_type=sql.TaskType.VOTE_INITIATE,
-            task_args=args.Initiate(
+        if promote:
+            self.__write_as.append_to_audit_log(
+                asf_uid=self.__asf_uid,
                 release_key=release.key,
-                email_to=email_to,
-                vote_duration=vote_duration_choice,
-                initiator_id=self.__asf_uid,
-                initiator_fullname=asf_fullname,
-                subject=subject,
-                body=body_data,
-                email_cc=email_cc or [],
-                email_bcc=email_bcc or [],
-                second_round_email_to=second_round_email_to,
-            ).model_dump(),
-            asf_uid=self.__asf_uid,
-            project_key=str(project_key),
-            version_key=str(version_key),
-        )
-        self.__data.add(task)
-        await self.__data.commit()
+                selected_revision_number=str(selected_revision_number),
+                vote_seq=vote_seq,
+                vote_mode=vote_mode.value,
+            )
 
         # TODO: We should log all outgoing email and the session so that users can confirm
         # And can be warned if there was a failure
@@ -287,7 +308,7 @@ class CommitteeMember(CommitteeParticipant):
             is_podling = release.project.committee.is_podling
         podling_thread_id = release.podling_thread_id
 
-        latest_vote_task = await interaction.release_latest_vote_task(release)
+        latest_vote_task = await interaction.release_current_vote_task(release)
         if latest_vote_task is None:
             raise RuntimeError("No vote task found, unable to send resolution message.")
 
@@ -386,7 +407,7 @@ class CommitteeMember(CommitteeParticipant):
         )
         return success_message
 
-    async def resolve_release(
+    async def resolve_release(  # noqa: C901
         self,
         project_key: safe.ProjectKey,
         release: sql.Release,
@@ -396,67 +417,82 @@ class CommitteeMember(CommitteeParticipant):
         asf_fullname: str,
         resolution_body: str,
     ) -> tuple[sql.Release, int | None, str, str | None]:
+        if (voting_round == 1) and (vote_result == "passed"):
+            await self.__data.commit()
+            await self.__data.begin_immediate()
         # Attach the existing release to the session
         release = await self.__data.merge(release)
         # Update the release phase based on vote result
         extra_destination = None
+        second_round_vote_seq = None
+        second_round_vote_mode = None
         if (voting_round == 1) and (vote_result == "passed"):
-            # This is the first podling vote, by the PPMC and not the Incubator PMC
-            # In this branch, we do not move to RELEASE_PREVIEW but keep everything the same
-            # We only set the podling_thread_id to the thread_id of the vote thread
-            # Then we automatically start the Incubator PMC vote
-            # TODO: Note on the resolve vote page that resolving the Project PPMC vote starts the Incubator PMC vote
-            task_mid = interaction.task_mid_get(latest_vote_task)
-            task_recipient = interaction.task_recipient_get(latest_vote_task)
-            archive_url = await self.__write_as.cache.get_message_archive_url(task_mid, task_recipient)
-            if archive_url is None:
-                raise ValueError("No archive URL found for podling vote")
-            thread_id = archive_url.split("/")[-1]
-            await self._resolve_transition(
-                release,
-                expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
-                expected_podling_thread_id=None,
-                new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
-                new_vote_mode=release.effective_vote_mode,
-                new_vote_resolved=None,
-                new_podling_thread_id=thread_id,
-            )
-            await self.__data.refresh(release)
-            incubator_vote_address = (
-                latest_vote_task.task_args.get("second_round_email_to") or util.INCUBATOR_GENERAL_ADDRESS
-            )
-            if not release.project.committee:
-                raise ValueError("Project has no committee - Invalid state")
-            revision_number = release.latest_revision_number
-            if revision_number is None:
-                raise ValueError("Release has no revision number - Invalid state")
-            vote_duration = latest_vote_task.task_args["vote_duration"]
-            subject_template = await construct.start_vote_subject_default(release.safe_project_key)
-            body_template = await construct.start_vote_default(release.safe_project_key)
-            options = construct.StartVoteOptions(
-                asfuid=self.__asf_uid,
-                fullname=asf_fullname,
-                project_key=release.safe_project_key,
-                version_key=release.safe_version_key,
-                revision_number=release.safe_latest_revision_number,
-                vote_duration=vote_duration,
-            )
-            subject_data, body_data = await construct.start_vote_subject_and_body(
-                subject_template, body_template, options
-            )
-            await self.start(
-                email_to=incubator_vote_address,
-                permitted_recipients=[incubator_vote_address],
-                project_key=release.safe_project_key,
-                version_key=release.safe_version_key,
-                selected_revision_number=release.safe_latest_revision_number,
-                asf_fullname=asf_fullname,
-                vote_duration_choice=vote_duration,
-                subject=subject_data,
-                body_data=body_data,
-                release=release,
-                promote=False,
-            )
+            try:
+                # This is the first podling vote, by the PPMC and not the Incubator PMC
+                # In this branch, we do not move to RELEASE_PREVIEW but keep everything the same
+                # We only set the podling_thread_id to the thread_id of the vote thread
+                # Then we automatically start the Incubator PMC vote
+                # TODO: Note on the resolve vote page that resolving the Project PPMC vote starts the Incubator PMC vote
+                task_mid = interaction.task_mid_get(latest_vote_task)
+                task_recipient = interaction.task_recipient_get(latest_vote_task)
+                archive_url = await self.__write_as.cache.get_message_archive_url(task_mid, task_recipient)
+                if archive_url is None:
+                    raise ValueError("No archive URL found for podling vote")
+                thread_id = archive_url.split("/")[-1]
+                await self._resolve_transition(
+                    release,
+                    expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                    expected_podling_thread_id=None,
+                    new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                    new_vote_mode=release.effective_vote_mode,
+                    new_vote_resolved=None,
+                    new_podling_thread_id=thread_id,
+                )
+                await self.__data.refresh(release)
+                incubator_vote_address = (
+                    latest_vote_task.task_args.get("second_round_email_to") or util.INCUBATOR_GENERAL_ADDRESS
+                )
+                if not release.project.committee:
+                    raise ValueError("Project has no committee - Invalid state")
+                revision_number = release.latest_revision_number
+                if revision_number is None:
+                    raise ValueError("Release has no revision number - Invalid state")
+                vote_duration = latest_vote_task.task_args["vote_duration"]
+                subject_template = await construct.start_vote_subject_default(release.safe_project_key)
+                body_template = await construct.start_vote_default(release.safe_project_key)
+                options = construct.StartVoteOptions(
+                    asfuid=self.__asf_uid,
+                    fullname=asf_fullname,
+                    project_key=release.safe_project_key,
+                    version_key=release.safe_version_key,
+                    revision_number=release.safe_latest_revision_number,
+                    vote_duration=vote_duration,
+                )
+                subject_data, body_data = await construct.start_vote_subject_and_body(
+                    subject_template, body_template, options
+                )
+                second_round_task = await self.start(
+                    email_to=incubator_vote_address,
+                    permitted_recipients=[incubator_vote_address],
+                    project_key=release.safe_project_key,
+                    version_key=release.safe_version_key,
+                    selected_revision_number=release.safe_latest_revision_number,
+                    asf_fullname=asf_fullname,
+                    vote_duration_choice=vote_duration,
+                    subject=subject_data,
+                    body_data=body_data,
+                    release=release,
+                    promote=False,
+                )
+                second_round_vote_seq = second_round_task.task_args["vote_seq"]
+                if not isinstance(second_round_vote_seq, int):
+                    raise ValueError("Second round vote sequence is invalid")
+                second_round_vote_mode = release.effective_vote_mode
+                await self.__data.commit()
+                await self.__data.refresh(release)
+            except Exception:
+                await self.__data.rollback()
+                raise
             success_message = (
                 f"Project PPMC vote marked as passed, and Incubator PMC vote automatically started"
                 f" (sent to {incubator_vote_address})"
@@ -512,13 +548,27 @@ class CommitteeMember(CommitteeParticipant):
             extra_destination=extra_destination,
         )
         # TODO: Could move this up before send_resolution
-        self.__write_as.append_to_audit_log(
-            asf_uid=self.__asf_uid,
-            project_key=str(project_key),
-            version_key=release.version,
-            vote_result=vote_result,
-            voting_round=voting_round,
-        )
+        if (second_round_vote_seq is not None) and (second_round_vote_mode is not None):
+            self.__write_as.append_to_audit_log(
+                asf_uid=self.__asf_uid,
+                release_key=release.key,
+                project_key=str(project_key),
+                version_key=release.version,
+                selected_revision_number=str(release.safe_latest_revision_number),
+                vote_seq=second_round_vote_seq,
+                vote_mode=second_round_vote_mode.value,
+                vote_result=vote_result,
+                voting_round=voting_round,
+                second_round_vote_seq=second_round_vote_seq,
+            )
+        else:
+            self.__write_as.append_to_audit_log(
+                asf_uid=self.__asf_uid,
+                project_key=str(project_key),
+                version_key=release.version,
+                vote_result=vote_result,
+                voting_round=voting_round,
+            )
         return release, voting_round, success_message, error_message
 
     async def send_resolution(
diff --git a/atr/tasks/vote.py b/atr/tasks/vote.py
index 697cfa13..6e231c6c 100644
--- a/atr/tasks/vote.py
+++ b/atr/tasks/vote.py
@@ -24,6 +24,7 @@ import atr.mail as mail
 import atr.models.args as args
 import atr.models.results as results
 import atr.models.safe as safe
+import atr.models.sql as sql
 import atr.storage as storage
 import atr.tasks.checks as checks
 import atr.util as util
@@ -47,7 +48,7 @@ async def initiate(task_args: args.Initiate) -> results.Results | None:
         raise
 
 
-async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | None:
+async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | None:  # noqa: C901
     """Get arguments, create an email, and then send it to the recipient."""
     log.info("Starting initiate_core")
     safe.ReleaseKey(task_args.release_key)
@@ -66,6 +67,13 @@ async def _initiate_core_logic(task_args: args.Initiate) -> results.Results | No
         latest_revision_number = release.latest_revision_number
         if latest_revision_number is None:
             raise VoteInitiationError(f"No revisions found for release {task_args.release_key!s}")
+        if release.phase != sql.ReleasePhase.RELEASE_CANDIDATE:
+            raise VoteInitiationError(f"Vote task is stale for release {task_args.release_key!s}")
+        if task_args.vote_seq is None:
+            if release.current_vote_seq is not None:
+                raise VoteInitiationError(f"Vote task is stale for release {task_args.release_key!s}")
+        elif release.current_vote_seq != task_args.vote_seq:
+            raise VoteInitiationError(f"Vote task is stale for release {task_args.release_key!s}")
 
         ongoing_tasks = await interaction.tasks_ongoing(
             release.safe_project_key, release.safe_version_key, release.safe_latest_revision_number
diff --git a/tests/unit/test_vote_recipients.py b/tests/unit/test_vote_recipients.py
index fc1d347c..dcfa79a8 100644
--- a/tests/unit/test_vote_recipients.py
+++ b/tests/unit/test_vote_recipients.py
@@ -25,6 +25,7 @@ import atr.db.interaction as interaction
 import atr.models.args as args
 import atr.models.results as results
 import atr.models.safe as safe
+import atr.models.sql as sql
 import atr.storage.writers.vote as vote
 import atr.tasks.vote
 import atr.util as util
@@ -45,6 +46,21 @@ def test_initiate_args_accepts_second_round_email_to() -> None:
     assert task_args.second_round_email_to == "general@incubator.apache.org"
 
 
+def test_initiate_args_accepts_vote_seq() -> None:
+    task_args = args.Initiate(
+        release_key="project-1.0.0",
+        email_to="dev@project.apache.org",
+        vote_duration=72,
+        initiator_id="testuser",
+        initiator_fullname="Test User",
+        subject="[VOTE] Release",
+        body="Please vote",
+        vote_seq=3,
+    )
+
+    assert task_args.vote_seq == 3
+
+
 def test_initiate_args_backward_compatible_without_second_round_field() -> None:
     raw = {
         "release_key": "project-1.0.0",
@@ -61,6 +77,7 @@ def test_initiate_args_backward_compatible_without_second_round_field() -> None:
     task_args = args.Initiate.model_validate(raw)
 
     assert task_args.second_round_email_to is None
+    assert task_args.vote_seq is None
 
 
 def test_permitted_podling_second_round_recipients_includes_incubator_address(
@@ -119,6 +136,45 @@ async def test_send_resolution_reuses_original_vote_recipients() -> None:
     assert queued_task.task_args["email_bcc"] == ["secretary@project.apache.org"]
 
 
+@pytest.mark.asyncio
+async def test_start_email_vote_sets_vote_seq_on_task() -> None:
+    data = mock.MagicMock()
+    data.add = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
+    data.commit = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
+
+    release = SimpleNamespace(
+        key="project-1.0.0",
+        committee=SimpleNamespace(key="project", is_podling=False),
+    )
+    release_writer = SimpleNamespace(
+        _start_vote_no_commit=mock.AsyncMock(return_value=(release, 7, sql.VoteMode.EMAIL)),
+    )
+    write_as = SimpleNamespace(release=release_writer, append_to_audit_log=mock.MagicMock())
+    writer = _participant_writer_with_mocks(data, write_as)
+
+    task = await writer.start(
+        "dev@project.apache.org",
+        safe.ProjectKey("project"),
+        safe.VersionKey("1.0.0"),
+        safe.RevisionNumber("00001"),
+        72,
+        "[VOTE] Release",
+        "Please vote",
+        "Project Chair",
+        permitted_recipients=["dev@project.apache.org"],
+    )
+
+    assert task.task_args["vote_seq"] == 7
+    data.add.assert_called_once_with(task)
+    data.begin_immediate.assert_awaited_once()
+    data.commit.assert_awaited_once()
+    data.rollback.assert_not_awaited()
+    write_as.append_to_audit_log.assert_called_once()
+    assert write_as.append_to_audit_log.call_args.kwargs["vote_seq"] == 7
+
+
 def test_task_recipient_get_returns_full_vote_recipient() -> None:
     latest_vote_task = _latest_vote_task()
 
@@ -142,6 +198,31 @@ async def test_worker_accepts_incubator_address_for_podling_round_two(
     assert result.email_to == "general@incubator.apache.org"
 
 
+@pytest.mark.asyncio
+async def test_worker_rejects_stale_vote_seq(monkeypatch: pytest.MonkeyPatch) -> None:
+    release = _make_release(is_podling=False, podling_thread_id=None, current_vote_seq=2)
+    _patch_db_and_interaction(monkeypatch, release)
+    _patch_storage_write(monkeypatch)
+
+    task_args = _make_initiate_args(email_to="dev@project.apache.org")
+    task_args.vote_seq = 1
+
+    with pytest.raises(atr.tasks.vote.VoteInitiationError, match="stale"):
+        await atr.tasks.vote._initiate_core_logic(task_args)
+
+
+@pytest.mark.asyncio
+async def test_worker_rejects_vote_task_outside_candidate_phase(monkeypatch: pytest.MonkeyPatch) -> None:
+    release = _make_release(is_podling=False, podling_thread_id=None, phase=sql.ReleasePhase.RELEASE)
+    _patch_db_and_interaction(monkeypatch, release)
+    _patch_storage_write(monkeypatch)
+
+    task_args = _make_initiate_args(email_to="dev@project.apache.org")
+
+    with pytest.raises(atr.tasks.vote.VoteInitiationError, match="stale"):
+        await atr.tasks.vote._initiate_core_logic(task_args)
+
+
 @pytest.mark.asyncio
 async def test_worker_skips_recipient_check_for_podling_round_two(
     monkeypatch: pytest.MonkeyPatch,
@@ -208,8 +289,16 @@ def _make_initiate_args(email_to: str, initiator_id: str = "testuser") -> args.I
     )
 
 
-def _make_release(*, is_podling: bool, podling_thread_id: str | None) -> SimpleNamespace:
+def _make_release(
+    *,
+    is_podling: bool,
+    podling_thread_id: str | None,
+    current_vote_seq: int | None = None,
+    phase: sql.ReleasePhase = sql.ReleasePhase.RELEASE_CANDIDATE,
+) -> SimpleNamespace:
     return SimpleNamespace(
+        phase=phase,
+        current_vote_seq=current_vote_seq,
         latest_revision_number="00001",
         safe_project_key=safe.ProjectKey("testproject"),
         safe_version_key=safe.VersionKey("1.0.0"),
@@ -222,6 +311,15 @@ def _make_release(*, is_podling: bool, podling_thread_id: str | None) -> SimpleN
     )
 
 
+def _participant_writer_with_mocks(data: mock.MagicMock, write_as: SimpleNamespace) -> vote.CommitteeParticipant:
+    writer = object.__new__(vote.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__write_as = write_as
+    writer._CommitteeParticipant__asf_uid = "chair"
+    writer._CommitteeParticipant__committee_key = "project"
+    return writer
+
+
 def _patch_db_and_interaction(monkeypatch: pytest.MonkeyPatch, release: SimpleNamespace) -> None:
     query = mock.AsyncMock()
     query.demand = mock.AsyncMock(return_value=release)
diff --git a/tests/unit/test_vote_resolution.py b/tests/unit/test_vote_resolution.py
index 92ce163e..f31a67a9 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -436,7 +436,7 @@ async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: pytest.Monk
     data.merge = mock.AsyncMock(return_value=release)
 
     future_task = _latest_vote_task_with_end(24)
-    monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
     monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
 
     writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote marked as cancelled", None))
@@ -466,7 +466,7 @@ async def test_resolve_allows_early_passed_with_bypass(monkeypatch: pytest.Monke
     data.merge = mock.AsyncMock(return_value=release)
 
     future_task = _latest_vote_task_with_end(24)
-    monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
     monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: True)
 
     writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote marked as passed", None))
@@ -496,7 +496,7 @@ async def test_resolve_allows_passed_after_vote_end(monkeypatch: pytest.MonkeyPa
     data.merge = mock.AsyncMock(return_value=release)
 
     past_task = _latest_vote_task_with_end(-24)
-    monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=past_task))
+    monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=past_task))
     monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
 
     writer.resolve_release = mock.AsyncMock(return_value=(release, None, "Vote marked as passed", None))
@@ -600,7 +600,7 @@ async def test_resolve_rejects_early_failed(monkeypatch: pytest.MonkeyPatch) ->
     data.merge = mock.AsyncMock(return_value=release)
 
     future_task = _latest_vote_task_with_end(24)
-    monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
     monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
 
     with pytest.raises(storage.AccessError, match="unless it is cancelled"):
@@ -626,7 +626,7 @@ async def test_resolve_rejects_early_passed(monkeypatch: pytest.MonkeyPatch) ->
     data.release = mock.MagicMock(return_value=query)
 
     future_task = _latest_vote_task_with_end(24)
-    monkeypatch.setattr(interaction, "release_latest_vote_task", mock.AsyncMock(return_value=future_task))
+    monkeypatch.setattr(interaction, "release_current_vote_task", mock.AsyncMock(return_value=future_task))
     monkeypatch.setattr(interaction, "vote_duration_bypass", lambda: False)
 
     with pytest.raises(storage.AccessError, match="voting period"):
@@ -828,6 +828,7 @@ def _mock_cursor_result(rowcount: int = 1) -> mock.MagicMock:
 
 def _mock_data() -> mock.MagicMock:
     data = mock.MagicMock()
+    data.begin_immediate = mock.AsyncMock()
     data.commit = mock.AsyncMock()
     data.execute = mock.AsyncMock(return_value=_mock_cursor_result())
     data.flush = mock.AsyncMock()
@@ -903,7 +904,7 @@ async def _render_standard_resolve_page(
 
     monkeypatch.setattr(resolve.util, "as_url", lambda _endpoint, **_kwargs: "/resolve/project/1.0.0")
     monkeypatch.setattr(
-        resolve.interaction, "release_latest_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
+        resolve.interaction, "release_current_vote_task", mock.AsyncMock(return_value=_latest_vote_task())
     )
     monkeypatch.setattr(resolve.interaction, "vote_duration_bypass", lambda: False)
     monkeypatch.setattr(resolve.interaction, "vote_end_get", lambda _task: None)
diff --git a/tests/unit/test_vote_serials.py b/tests/unit/test_vote_serials.py
new file mode 100644
index 00000000..be2b8f6f
--- /dev/null
+++ b/tests/unit/test_vote_serials.py
@@ -0,0 +1,250 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+import datetime
+import unittest.mock as mock
+from collections.abc import AsyncIterator
+from types import SimpleNamespace
+
+import pytest
+import sqlalchemy
+import sqlalchemy.ext.asyncio
+import sqlmodel
+
+import atr.db as db
+import atr.db.interaction as interaction
+import atr.models.safe as safe
+import atr.models.sql as sql
+import atr.storage.writers.release as release
+import atr.storage.writers.vote as vote
+import atr.util as util
+
+
+@pytest.fixture
+async def sqlite_sessionmaker() -> AsyncIterator[sqlalchemy.ext.asyncio.async_sessionmaker[db.Session]]:
+    engine = sqlalchemy.ext.asyncio.create_async_engine(
+        "sqlite+aiosqlite:///:memory:",
+        connect_args={"check_same_thread": False},
+        poolclass=sqlalchemy.pool.StaticPool,
+    )
+    async with engine.begin() as conn:
+        await conn.run_sync(sqlmodel.SQLModel.metadata.create_all)
+
+    sessionmaker = sqlalchemy.ext.asyncio.async_sessionmaker(bind=engine, class_=db.Session, expire_on_commit=False)
+    yield sessionmaker
+    await engine.dispose()
+
+
+@pytest.mark.asyncio
+async def test_podling_second_round_rolls_back_with_task_creation(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as data:
+        seeded_release = await _seed_release(
+            data,
+            phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+            current_vote_seq=1,
+            is_podling=True,
+            vote_mode=sql.VoteMode.EMAIL,
+        )
+        data.add(sql.VoteCounter(release_key=seeded_release.key, last_allocated_number=1))
+        await data.commit()
+
+        release_model = await data.release(
+            key="project-1.0.0",
+            _project=True,
+            _committee=True,
+            _project_release_policy=True,
+        ).demand(RuntimeError("release missing"))
+        release_participant = _release_writer_with_data(data)
+        write_as = SimpleNamespace(release=release_participant, append_to_audit_log=mock.MagicMock())
+        writer = _member_writer_with_data(data, write_as)
+
+        with pytest.raises(RuntimeError, match="task creation failed"):
+            try:
+                await data.begin_immediate()
+                await writer._resolve_transition(
+                    release_model,
+                    expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                    expected_podling_thread_id=None,
+                    new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                    new_vote_mode=sql.VoteMode.EMAIL,
+                    new_vote_resolved=None,
+                    new_podling_thread_id="thread",
+                )
+                await data.refresh(release_model)
+                task = await writer.start(
+                    email_to=util.INCUBATOR_GENERAL_ADDRESS,
+                    permitted_recipients=[util.INCUBATOR_GENERAL_ADDRESS],
+                    project_key=release_model.safe_project_key,
+                    version_key=release_model.safe_version_key,
+                    selected_revision_number=release_model.safe_latest_revision_number,
+                    asf_fullname="Chair",
+                    vote_duration_choice=72,
+                    subject="[VOTE] Release",
+                    body_data="Please vote",
+                    release=release_model,
+                    promote=False,
+                )
+                assert task.task_args["vote_seq"] == 2
+                raise RuntimeError("task creation failed")
+            except Exception:
+                await data.rollback()
+                raise
+
+    async with sqlite_sessionmaker() as data:
+        release_model = await data.release(key="project-1.0.0").demand(RuntimeError("release missing"))
+        counter = await data.get(sql.VoteCounter, "project-1.0.0")
+        assert counter is not None
+        assert release_model.podling_thread_id is None
+        assert release_model.current_vote_seq == 1
+        assert counter.last_allocated_number == 1
+
+
+@pytest.mark.asyncio
+async def test_release_current_vote_task_matches_serial_and_legacy_fallback(sqlite_sessionmaker) -> None:
+    async with sqlite_sessionmaker() as data:
+        release_model = await _seed_release(data, phase=sql.ReleasePhase.RELEASE_CANDIDATE, current_vote_seq=2)
+        older = _vote_task(1, datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC))
+        current = _vote_task(2, datetime.datetime(2026, 1, 2, tzinfo=datetime.UTC))
+        data.add_all([older, current])
+        await data.commit()
+
+        matched = await interaction.release_current_vote_task(release_model, data)
+        assert matched is not None
+        assert matched.task_args["vote_seq"] == 2
+
+        release_model.current_vote_seq = 3
+        await data.commit()
+        assert await interaction.release_current_vote_task(release_model, data) is None
+
+        release_model.current_vote_seq = None
+        await data.commit()
+        legacy = await interaction.release_current_vote_task(release_model, data)
+        assert legacy is not None
+        assert legacy.task_args["vote_seq"] == 2
+
+
+@pytest.mark.asyncio
+async def test_vote_start_allocation_rolls_back_with_task_creation(sqlite_sessionmaker, monkeypatch) -> None:
+    monkeypatch.setattr(release.util, "number_of_release_files", mock.AsyncMock(return_value=1))
+    async with sqlite_sessionmaker() as data:
+        await _seed_release(data, phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT)
+        writer = _release_writer_with_data(data)
+
+        with pytest.raises(RuntimeError, match="task creation failed"):
+            try:
+                await data.begin_immediate()
+                _release, vote_seq, _vote_mode = await writer._start_vote_no_commit(
+                    safe.ReleaseKey("project-1.0.0"),
+                    safe.RevisionNumber("00001"),
+                    allowed_vote_modes=frozenset({sql.VoteMode.EMAIL}),
+                    promote=True,
+                )
+                data.add(_vote_task(vote_seq, datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC)))
+                raise RuntimeError("task creation failed")
+            except Exception:
+                await data.rollback()
+                raise
+
+    async with sqlite_sessionmaker() as data:
+        release_model = await data.release(key="project-1.0.0").demand(RuntimeError("release missing"))
+        counter = await data.get(sql.VoteCounter, "project-1.0.0")
+        latest_task = await interaction.release_latest_vote_task(release_model, data)
+        assert release_model.phase == sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
+        assert release_model.current_vote_seq is None
+        assert counter is None
+        assert latest_task is None
+
+
+def _member_writer_with_data(data: db.Session, write_as: SimpleNamespace) -> vote.CommitteeMember:
+    writer = object.__new__(vote.CommitteeMember)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__write_as = write_as
+    writer._CommitteeParticipant__asf_uid = "chair"
+    writer._CommitteeParticipant__committee_key = "project"
+    writer._CommitteeMember__data = data
+    writer._CommitteeMember__write_as = write_as
+    writer._CommitteeMember__asf_uid = "chair"
+    writer._CommitteeMember__committee_key = "project"
+    return writer
+
+
+def _release_writer_with_data(data: db.Session) -> release.CommitteeParticipant:
+    writer = object.__new__(release.CommitteeParticipant)
+    writer._CommitteeParticipant__data = data
+    writer._CommitteeParticipant__asf_uid = "chair"
+    return writer
+
+
+async def _seed_release(
+    data: db.Session,
+    *,
+    phase: sql.ReleasePhase,
+    current_vote_seq: int | None = None,
+    is_podling: bool = False,
+    vote_mode: sql.VoteMode | None = None,
+) -> sql.Release:
+    committee = sql.Committee(
+        key="project",
+        name="Project",
+        is_podling=is_podling,
+        committee_members=["chair"],
+        committers=["chair"],
+    )
+    project = sql.Project(key="project", name="Project", committee=committee)
+    release = sql.Release(
+        key="project-1.0.0",
+        phase=phase,
+        project=project,
+        project_key=project.key,
+        version="1.0.0",
+        created=datetime.datetime(2026, 1, 1, tzinfo=datetime.UTC),
+        current_vote_seq=current_vote_seq,
+        vote_mode=vote_mode,
+    )
+    revision = sql.Revision(
+        key="project-1.0.0 00001",
+        release=release,
+        release_key=release.key,
+        seq=1,
+        number="00001",
+        asfuid="chair",
+        phase=phase,
+    )
+    data.add_all([committee, project, release, revision])
+    await data.commit()
+    return release
+
+
+def _vote_task(vote_seq: int, added: datetime.datetime) -> sql.Task:
+    return sql.Task(
+        status=sql.TaskStatus.QUEUED,
+        task_type=sql.TaskType.VOTE_INITIATE,
+        task_args={
+            "release_key": "project-1.0.0",
+            "email_to": "dev@project.apache.org",
+            "vote_duration": 72,
+            "initiator_id": "chair",
+            "initiator_fullname": "Chair",
+            "subject": "[VOTE] Release",
+            "body": "Please vote",
+            "vote_seq": vote_seq,
+        },
+        asf_uid="chair",
+        project_key="project",
+        version_key="1.0.0",
+        added=added,
+    )


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org