Commit 202e9715 authored by George Papathanail's avatar George Papathanail
Browse files

fix: mypy failures

parent 690f73fb
Loading
Loading
Loading
Loading
Loading
+38 −0
Original line number Diff line number Diff line
@@ -285,6 +285,28 @@ _SETTLED_APP_INSTANCE_STATES = frozenset(
)


def _synthetic_failure_completion(event: SRMOperationStatus) -> SRMOperationCompleted:
    """Recast a failed/failed_before_start event.srm.operation.status as a
    total-failure event.srm.operation.completed so handle_completed's existing
    failure handling (instance/deployment transitions, callback delivery) can
    run early, without waiting for SRM's own completed event to arrive."""
    title = (
        "SRM rejected the command before starting."
        if event.state == "failed_before_start"
        else "SRM reported the operation as failed."
    )
    return SRMOperationCompleted(
        schema_version=event.schema_version,
        operation_id=event.operation_id,
        status="failed",
        service_order_id=event.service_order_id,
        instances=[],
        error={"type": "about:blank", "title": title, "status": 502},
        correlation_id=event.correlation_id,
        completed_at=event.emitted_at,
    )


def _expected_instance_keys(operation: Operation) -> Optional[set[str]]:
    """The fan-out target set recorded when a multi-zone /deployments request
    put N commands under one operation_id.
@@ -1278,6 +1300,22 @@ class EdgeApplicationManagementService:
        if self._operation_repo is None:
            raise RuntimeError("OperationRepository is not available")

        if event.state in ("failed", "failed_before_start"):
            # SRM always follows a failed/failed_before_start status event with
            # a matching event.srm.operation.completed(status=failed) for the
            # same operation_id. Route through handle_completed rather than
            # writing FAILED here directly: that keeps the instance/deployment
            # transitions and callback delivery in one place, and lets that
            # method's own terminal-status guard treat the (guaranteed) later
            # completed event as the safe, idempotent redelivery it is.
            logger.info(
                "operation_early_failure_from_status_event",
                operation_id=event.operation_id,
                state=event.state,
            )
            await self.handle_completed(_synthetic_failure_completion(event))
            return

        operation_id = UUID(event.operation_id)
        operation = await self._operation_repo.get_by_id(operation_id)
        if operation is None:
+131 −3
Original line number Diff line number Diff line
from datetime import datetime, timedelta, timezone
from typing import Any
from typing import Any, Literal
from unittest.mock import AsyncMock
from uuid import UUID, uuid4

@@ -4534,15 +4534,22 @@ class TestHandleOperationStatus:

    OPERATION_ID = UUID("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee")

    def _accepted_event(self, operation_id: UUID = OPERATION_ID) -> SRMOperationStatus:
    def _status_event(
        self,
        state: Literal["accepted", "failed_before_start", "completed", "failed", "in_progress"],
        operation_id: UUID = OPERATION_ID,
    ) -> SRMOperationStatus:
        return SRMOperationStatus(
            schema_version="1.0",
            operation_id=str(operation_id),
            state="accepted",
            state=state,
            correlation_id="corr-1",
            emitted_at="2026-07-04T10:00:05+00:00",
        )

    def _accepted_event(self, operation_id: UUID = OPERATION_ID) -> SRMOperationStatus:
        return self._status_event("accepted", operation_id)

    async def _seed_operation(
        self,
        operation_repo: FakeOperationRepository,
@@ -4629,6 +4636,127 @@ class TestHandleOperationStatus:

        assert await operation_repo.get_by_id(unknown_operation_id) is None

    @pytest.mark.parametrize("failure_state", ["failed", "failed_before_start"])
    async def test_failure_state_moves_pending_deploy_operation_and_instance_to_failed(
        self,
        service: EdgeApplicationManagementService,
        operation_repo: FakeOperationRepository,
        app_instance_repo: FakeAppInstanceRepository,
        failure_state: Literal["failed", "failed_before_start"],
    ) -> None:
        """A failed/failed_before_start status event carries no instances[] of
        its own -- it must still flip the pre-created app_instance row to
        failed, via the same total-failure path handle_completed already
        uses, rather than leaving it stuck instantiating."""
        await self._seed_operation(operation_repo, OperationStatus.PENDING)
        await app_instance_repo.save(
            AppInstance(
                app_instance_id=INSTANCE_ID,
                operation_id=self.OPERATION_ID,
                app_registration_id=uuid4(),
                edge_cloud_zone_id=ZONE_ID,
                state=AppInstanceState.INSTANTIATING,
            )
        )

        await service.handle_operation_status(self._status_event(failure_state))

        updated_operation = await operation_repo.get_by_id(self.OPERATION_ID)
        assert updated_operation is not None
        assert updated_operation.status == OperationStatus.FAILED

        updated_instance = await app_instance_repo.get_by_id(INSTANCE_ID)
        assert updated_instance is not None
        assert updated_instance.state == AppInstanceState.FAILED

    async def test_failed_moves_in_progress_terminate_operation_to_failed(
        self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository
    ) -> None:
        await self._seed_operation(
            operation_repo, OperationStatus.IN_PROGRESS, operation_type=OperationType.TERMINATE
        )

        await service.handle_operation_status(self._status_event("failed"))

        updated = await operation_repo.get_by_id(self.OPERATION_ID)
        assert updated is not None
        assert updated.status == OperationStatus.FAILED

    @pytest.mark.parametrize(
        "terminal_status",
        [OperationStatus.COMPLETED, OperationStatus.PARTIALLY_COMPLETED, OperationStatus.FAILED],
    )
    async def test_redelivered_failure_state_on_terminal_operation_is_noop(
        self,
        service: EdgeApplicationManagementService,
        operation_repo: FakeOperationRepository,
        terminal_status: OperationStatus,
    ) -> None:
        await self._seed_operation(operation_repo, terminal_status)

        await service.handle_operation_status(self._status_event("failed_before_start"))

        updated = await operation_repo.get_by_id(self.OPERATION_ID)
        assert updated is not None
        assert updated.status == terminal_status

    async def test_failure_state_for_unknown_operation_is_noop(
        self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository
    ) -> None:
        unknown_operation_id = uuid4()

        await service.handle_operation_status(
            self._status_event("failed_before_start", unknown_operation_id)
        )

        assert await operation_repo.get_by_id(unknown_operation_id) is None

    async def test_later_real_completed_failure_is_a_safe_redelivery_after_early_failure(
        self,
        service: EdgeApplicationManagementService,
        operation_repo: FakeOperationRepository,
        app_instance_repo: FakeAppInstanceRepository,
    ) -> None:
        """SRM always follows failed_before_start with its own
        event.srm.operation.completed(status=failed) for the same
        operation_id. By the time that arrives, this operation has already
        been failed via the status event -- handle_completed's own terminal
        guard must treat it as a harmless redelivery, not raise or re-process
        it."""
        await self._seed_operation(operation_repo, OperationStatus.PENDING)
        await app_instance_repo.save(
            AppInstance(
                app_instance_id=INSTANCE_ID,
                operation_id=self.OPERATION_ID,
                app_registration_id=uuid4(),
                edge_cloud_zone_id=ZONE_ID,
                state=AppInstanceState.INSTANTIATING,
            )
        )
        await service.handle_operation_status(self._status_event("failed_before_start"))

        real_completed_event = SRMOperationCompleted(
            schema_version="1.0",
            operation_id=str(self.OPERATION_ID),
            status="failed",
            instances=[],
            error={
                "type": "https://etsi.org/sdg/oop/problems/invalid-command",
                "title": "Deploy service command is invalid.",
                "status": 400,
            },
            correlation_id="corr-1",
            completed_at="2026-07-04T10:02:35+00:00",
        )
        await service.handle_completed(real_completed_event)

        updated_operation = await operation_repo.get_by_id(self.OPERATION_ID)
        assert updated_operation is not None
        assert updated_operation.status == OperationStatus.FAILED
        updated_instance = await app_instance_repo.get_by_id(INSTANCE_ID)
        assert updated_instance is not None
        assert updated_instance.state == AppInstanceState.FAILED


class TestReconcileStaleOperations:
    """The at-most-once NATS subject can drop event.srm.operation.completed, which