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 026834ba Add conditional queries to perform atomic vote transitions
026834ba is described below

commit 026834ba51aab39b9107823af447a25a55d313ec
Author: Sean B. Palmer <[email protected]>
AuthorDate: Fri Apr 3 20:22:54 2026 +0100

    Add conditional queries to perform atomic vote transitions
---
 atr/storage/writers/vote.py        |  84 +++++++++++++++++++++++----
 tests/unit/test_vote_resolution.py | 114 +++++++++++++++++++++++++++++++++++++
 2 files changed, 187 insertions(+), 11 deletions(-)

diff --git a/atr/storage/writers/vote.py b/atr/storage/writers/vote.py
index fd21ff41..c2562d0b 100644
--- a/atr/storage/writers/vote.py
+++ b/atr/storage/writers/vote.py
@@ -21,6 +21,8 @@ from __future__ import annotations
 import datetime
 from typing import Literal
 
+import sqlmodel
+
 import atr.construct as construct
 import atr.db as db
 import atr.db.interaction as interaction
@@ -312,8 +314,14 @@ class CommitteeMember(CommitteeParticipant):
 
         match vote_result:
             case "passed":
-                release.phase = sql.ReleasePhase.RELEASE_PREVIEW
-                release.vote_resolved = datetime.datetime.now(datetime.UTC)
+                await self._resolve_transition(
+                    release,
+                    expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                    expected_podling_thread_id=None,
+                    new_phase=sql.ReleasePhase.RELEASE_PREVIEW,
+                    new_vote_resolved=datetime.datetime.now(datetime.UTC),
+                    new_podling_thread_id=None,
+                )
                 await self.__data.commit()
                 await self.__data.refresh(release)
                 success_message = "Vote marked as passed"
@@ -327,10 +335,15 @@ class CommitteeMember(CommitteeParticipant):
                     description=description,
                 )
             case "failed" | "cancelled":
-                release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
                 # The vote_resolved property refers to when the vote succeeded 
only
-                release.vote_resolved = None
-                release.podling_thread_id = None
+                await self._resolve_transition(
+                    release,
+                    expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                    expected_podling_thread_id=None,
+                    new_phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+                    new_vote_resolved=None,
+                    new_podling_thread_id=None,
+                )
                 await self.__data.commit()
                 await self.__data.refresh(release)
                 success_message = f"Vote marked as {vote_result}"
@@ -369,7 +382,15 @@ class CommitteeMember(CommitteeParticipant):
             if archive_url is None:
                 raise ValueError("No archive URL found for podling vote")
             thread_id = archive_url.split("/")[-1]
-            release.podling_thread_id = thread_id
+            await self._resolve_transition(
+                release,
+                expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                expected_podling_thread_id=None,
+                new_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                new_vote_resolved=None,
+                new_podling_thread_id=thread_id,
+            )
+            await self.__data.refresh(release)
             # incubator_vote_address = "[email protected]"
             incubator_vote_address = util.USER_TESTS_ADDRESS
             if not release.project.committee:
@@ -407,8 +428,14 @@ class CommitteeMember(CommitteeParticipant):
             )
             success_message = "Project PPMC vote marked as passed, and 
Incubator PMC vote automatically started"
         elif vote_result == "passed":
-            release.phase = sql.ReleasePhase.RELEASE_PREVIEW
-            release.vote_resolved = datetime.datetime.now(datetime.UTC)
+            await self._resolve_transition(
+                release,
+                expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                expected_podling_thread_id=release.podling_thread_id,
+                new_phase=sql.ReleasePhase.RELEASE_PREVIEW,
+                new_vote_resolved=datetime.datetime.now(datetime.UTC),
+                new_podling_thread_id=release.podling_thread_id,
+            )
             await self.__data.commit()
             await self.__data.refresh(release)
             success_message = "Vote marked as passed"
@@ -427,10 +454,15 @@ class CommitteeMember(CommitteeParticipant):
                 )
                 extra_destination = (round_one_email_address, 
round_one_message_id)
         else:
-            release.phase = sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT
             # The vote_resolved property refers to when the vote succeeded only
-            release.vote_resolved = None
-            release.podling_thread_id = None
+            await self._resolve_transition(
+                release,
+                expected_phase=sql.ReleasePhase.RELEASE_CANDIDATE,
+                expected_podling_thread_id=release.podling_thread_id,
+                new_phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT,
+                new_vote_resolved=None,
+                new_podling_thread_id=None,
+            )
             await self.__data.commit()
             await self.__data.refresh(release)
             success_message = f"Vote marked as {vote_result}"
@@ -532,6 +564,36 @@ class CommitteeMember(CommitteeParticipant):
         await self.__data.commit()
         return None
 
+    async def _resolve_transition(
+        self,
+        release: sql.Release,
+        *,
+        expected_phase: sql.ReleasePhase,
+        expected_podling_thread_id: str | None,
+        new_phase: sql.ReleasePhase,
+        new_vote_resolved: datetime.datetime | None,
+        new_podling_thread_id: str | None,
+    ) -> None:
+        via = sql.validate_instrumented_attribute
+        stmt = sqlmodel.update(sql.Release).where(
+            via(sql.Release.key) == release.key,
+            via(sql.Release.phase) == expected_phase,
+        )
+        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(
+                phase=new_phase,
+                vote_resolved=new_vote_resolved,
+                podling_thread_id=new_podling_thread_id,
+            )
+        )
+        if getattr(result, "rowcount", 0) != 1:
+            await self.__data.rollback()
+            raise storage.AccessError("The release state has changed, please 
refresh and try again")
+
     # def __committee_member_or_admin(self, committee: sql.Committee, asf_uid: 
str) -> None:
     #     if not (user.is_committee_member(committee, asf_uid) or 
user.is_admin(asf_uid)):
     #         raise storage.AccessError("You do not have permission to perform 
this action")
diff --git a/tests/unit/test_vote_resolution.py 
b/tests/unit/test_vote_resolution.py
index d4e30df1..31cb2f6e 100644
--- a/tests/unit/test_vote_resolution.py
+++ b/tests/unit/test_vote_resolution.py
@@ -21,6 +21,7 @@ from types import SimpleNamespace
 
 import pytest
 import quart
+import sqlalchemy.engine as engine
 
 import atr.db.interaction as interaction
 import atr.get.manual as manual
@@ -77,6 +78,9 @@ async def 
test_cancelled_resolve_release_clears_podling_thread_id() -> None:
 
     release = _candidate_release(podling_thread_id="abc123")
     data.merge = mock.AsyncMock(return_value=release)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, vote_resolved=None, 
podling_thread_id=None
+    )
 
     await writer.resolve_release(
         _project_key(),
@@ -100,6 +104,9 @@ async def 
test_cancelled_resolve_release_produces_correct_message() -> None:
 
     release = _candidate_release()
     data.merge = mock.AsyncMock(return_value=release)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, vote_resolved=None, 
podling_thread_id=None
+    )
 
     _release, _round, success, _error = await writer.resolve_release(
         _project_key(),
@@ -123,6 +130,9 @@ async def test_cancelled_resolve_release_returns_to_draft() 
-> None:
 
     release = _candidate_release()
     data.merge = mock.AsyncMock(return_value=release)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, vote_resolved=None, 
podling_thread_id=None
+    )
 
     _release, _round, success, _error = await writer.resolve_release(
         _project_key(),
@@ -149,6 +159,9 @@ async def 
test_failed_resolve_release_clears_podling_thread_id() -> None:
 
     release = _candidate_release(podling_thread_id="abc123")
     data.merge = mock.AsyncMock(return_value=release)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, vote_resolved=None, 
podling_thread_id=None
+    )
 
     await writer.resolve_release(
         _project_key(),
@@ -176,6 +189,9 @@ async def 
test_manual_cancelled_returns_to_draft_and_clears_podling_thread_id()
     query = mock.MagicMock()
     query.demand = mock.AsyncMock(return_value=release)
     data.release = mock.MagicMock(return_value=query)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, vote_resolved=None, 
podling_thread_id=None
+    )
 
     success = await writer.resolve_manually(
         _project_key(),
@@ -201,6 +217,9 @@ async def 
test_manual_failed_returns_to_draft_and_clears_podling_thread_id() ->
     query = mock.MagicMock()
     query.demand = mock.AsyncMock(return_value=release)
     data.release = mock.MagicMock(return_value=query)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_CANDIDATE_DRAFT, vote_resolved=None, 
podling_thread_id=None
+    )
 
     success = await writer.resolve_manually(
         _project_key(),
@@ -225,6 +244,9 @@ async def test_manual_passed_creates_preview_revision() -> 
None:
     query = mock.MagicMock()
     query.demand = mock.AsyncMock(return_value=release)
     data.release = mock.MagicMock(return_value=query)
+    data.refresh = _refresh_as(
+        phase=sql.ReleasePhase.RELEASE_PREVIEW, 
vote_resolved=datetime.datetime.now(datetime.UTC)
+    )
 
     success = await writer.resolve_manually(
         _project_key(),
@@ -264,6 +286,30 @@ async def 
test_manual_resolve_page_explains_cancellation_notice_url(
     assert "cancellation notice" in html
 
 
[email protected]
+async def test_manual_resolve_rejects_concurrent_modification() -> None:
+    """Manual resolve raises AccessError when the release was modified 
concurrently."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _manual_candidate_release()
+    query = mock.MagicMock()
+    query.demand = mock.AsyncMock(return_value=release)
+    data.release = mock.MagicMock(return_value=query)
+    data.execute = mock.AsyncMock(return_value=_mock_cursor_result(rowcount=0))
+
+    with pytest.raises(storage.AccessError, match="release state has changed"):
+        await writer.resolve_manually(
+            _project_key(),
+            _version_key(),
+            "passed",
+        )
+
+    data.rollback.assert_awaited()
+    write_as.revision.create_revision_with_quarantine.assert_not_awaited()
+
+
 def test_manual_vote_resolve_section_links_to_manual_resolve(monkeypatch: 
pytest.MonkeyPatch) -> None:
     """Manual vote releases link to the manual resolution page."""
 
@@ -290,6 +336,57 @@ def 
test_manual_vote_resolve_section_links_to_manual_resolve(monkeypatch: pytest
     assert 'href="/resolve/project/1.0.0"' not in html
 
 
[email protected]
+async def test_podling_double_pass_raises_error() -> None:
+    """A second podling round 1 pass raises AccessError when the first already 
set podling_thread_id."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    data.merge = mock.AsyncMock(return_value=release)
+    data.execute = mock.AsyncMock(return_value=_mock_cursor_result(rowcount=0))
+    write_as.cache.get_message_archive_url = 
mock.AsyncMock(return_value="https://lists.apache.org/thread/abc123";)
+
+    with pytest.raises(storage.AccessError, match="release state has changed"):
+        await writer.resolve_release(
+            _project_key(),
+            release,
+            1,
+            "passed",
+            _latest_vote_task(),
+            "Chair",
+            "The vote has passed.",
+        )
+
+    data.rollback.assert_awaited()
+
+
[email protected]
+async def test_podling_stale_round_one_cancel_after_pass() -> None:
+    """A stale cancel after another user passes podling round 1 raises 
AccessError."""
+    data = _mock_data()
+    write_as = _mock_write_as()
+    writer = _writer_with_mocks(data, write_as)
+
+    release = _candidate_release()
+    data.merge = mock.AsyncMock(return_value=release)
+    data.execute = mock.AsyncMock(return_value=_mock_cursor_result(rowcount=0))
+
+    with pytest.raises(storage.AccessError, match="release state has changed"):
+        await writer.resolve_release(
+            _project_key(),
+            release,
+            1,
+            "cancelled",
+            _latest_vote_task(),
+            "Chair",
+            "The vote has been cancelled.",
+        )
+
+    data.rollback.assert_awaited()
+
+
 @pytest.mark.asyncio
 async def test_resolve_allows_cancelled_before_vote_end(monkeypatch: 
pytest.MonkeyPatch) -> None:
     """Writer allows Cancelled even before the end of the vote."""
@@ -589,6 +686,7 @@ def _latest_vote_task_with_end(offset_hours: int) -> 
SimpleNamespace:
 
 def _manual_candidate_release(podling_thread_id: str | None = None) -> 
SimpleNamespace:
     return SimpleNamespace(
+        key="project-1.0.0",
         phase=sql.ReleasePhase.RELEASE_CANDIDATE,
         vote_manual=True,
         vote_started=datetime.datetime.now(datetime.UTC),
@@ -607,12 +705,20 @@ def _manual_candidate_release(podling_thread_id: str | 
None = None) -> SimpleNam
     )
 
 
+def _mock_cursor_result(rowcount: int = 1) -> mock.MagicMock:
+    result = mock.MagicMock(spec=engine.CursorResult)
+    result.rowcount = rowcount
+    return result
+
+
 def _mock_data() -> mock.MagicMock:
     data = mock.MagicMock()
     data.commit = mock.AsyncMock()
+    data.execute = mock.AsyncMock(return_value=_mock_cursor_result())
     data.flush = mock.AsyncMock()
     data.merge = mock.AsyncMock()
     data.refresh = mock.AsyncMock()
+    data.rollback = mock.AsyncMock()
     return data
 
 
@@ -627,6 +733,14 @@ def _project_key() -> safe.ProjectKey:
     return safe.ProjectKey("project")
 
 
+def _refresh_as(**attrs: object) -> mock.AsyncMock:
+    def _apply(obj: SimpleNamespace) -> None:
+        for k, v in attrs.items():
+            setattr(obj, k, v)
+
+    return mock.AsyncMock(side_effect=_apply)
+
+
 def _version_key() -> safe.VersionKey:
     return safe.VersionKey("1.0.0")
 


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to