Commit 0618735f authored by George Papathanail's avatar George Papathanail
Browse files

fix: flip all app_instances on multi-zone total-failure fallback

parent 4301eea7
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -25,6 +25,11 @@ class SqlAppInstanceRepository(AppInstanceRepository):
        row = await self._session.scalar(stmt)
        return AppInstanceMapper.to_domain(row) if row is not None else None

    async def list_by_operation_id(self, operation_id: UUID) -> list[AppInstance]:
        stmt = select(AppInstanceRow).where(AppInstanceRow.operation_id == operation_id)
        rows = await self._session.scalars(stmt)
        return [AppInstanceMapper.to_domain(row) for row in rows]

    async def exists_for_app_registration(self, app_registration_id: UUID) -> bool:
        stmt = select(AppInstanceRow.app_instance_id).where(
            AppInstanceRow.app_registration_id == app_registration_id,
+15 −7
Original line number Diff line number Diff line
@@ -722,6 +722,9 @@ class EdgeApplicationManagementService:
        elif status == OperationStatus.FAILED:
            # Total failure carries no instances[] entries to match against.
            if is_terminate:
                # Termination always targets exactly one instance (DELETE
                # /deployments doesn't exist yet, so there's no multi-instance
                # terminate operation to fan out over).
                raw_app_instance_id = operation.metadata.get("app_instance_id")
                app_instance = (
                    await self._app_instance_repo.get_by_id(UUID(raw_app_instance_id))
@@ -733,12 +736,17 @@ class EdgeApplicationManagementService:
                        "terminate_total_failure_missing_app_instance_id",
                        operation_id=event.operation_id,
                    )
                failed_instances = [app_instance] if app_instance is not None else []
            else:
                app_instance = await self._app_instance_repo.get_by_operation_id(operation_id)
            if app_instance is not None:
                # A deploy operation_id may own N pre-created rows (one per
                # zone for a multi-zone /deployments request); all of them
                # must flip to failed, not just whichever one a single-row
                # lookup happens to return.
                failed_instances = await self._app_instance_repo.list_by_operation_id(operation_id)
            for app_instance in failed_instances:
                if _is_final(app_instance):
                    _log_stale_completion(app_instance, operation_id)
                else:
                    continue
                saved = await self._app_instance_repo.save(
                    app_instance.model_copy(update={"state": AppInstanceState.FAILED})
                )
+4 −0
Original line number Diff line number Diff line
@@ -15,6 +15,10 @@ class AppInstanceRepository(ABC):
    async def get_by_operation_id(self, operation_id: UUID) -> AppInstance | None:
        pass

    @abstractmethod
    async def list_by_operation_id(self, operation_id: UUID) -> list[AppInstance]:
        pass

    @abstractmethod
    async def exists_for_app_registration(self, app_registration_id: UUID) -> bool:
        pass
+7 −0
Original line number Diff line number Diff line
@@ -456,6 +456,13 @@ class FakeAppInstanceRepository(AppInstanceRepository):
                return row.model_copy(deep=True)
        return None

    async def list_by_operation_id(self, operation_id: UUID) -> list[AppInstance]:
        return [
            row.model_copy(deep=True)
            for row in self.rows.values()
            if row.operation_id == operation_id
        ]

    async def exists_for_app_registration(self, app_registration_id: UUID) -> bool:
        terminal_states = (AppInstanceState.FAILED, AppInstanceState.TERMINATED)
        return any(
+72 −0
Original line number Diff line number Diff line
@@ -2015,6 +2015,78 @@ class TestHandleCompleted:
        assert updated is not None
        assert updated.state == AppInstanceState.FAILED

    async def test_multi_zone_total_failure_flips_every_pre_created_instance(
        self,
        service: EdgeApplicationManagementService,
        operation_repo: FakeOperationRepository,
        app_instance_repo: FakeAppInstanceRepository,
    ) -> None:
        """A multi-zone deploy operation_id owns N pre-created app_instances
        rows (one per targeted zone). A total failure with no instances[] must
        flip ALL of them to failed, not just whichever one a single-row lookup
        happens to return -- otherwise N-1 rows are orphaned in instantiating
        forever, since no further event will ever reference them."""
        zone_a_id = UUID("9a3f1c22-0000-4000-8000-00000000000a")
        zone_b_id = UUID("9a3f1c22-0000-4000-8000-00000000000b")
        await self._seed_pending_operation(operation_repo)
        await self._seed_instantiating_app_instance(app_instance_repo, zone_a_id)
        await self._seed_instantiating_app_instance(app_instance_repo, zone_b_id)
        event = SRMOperationCompleted(
            schema_version="1.0",
            operation_id=str(self.OPERATION_ID),
            status="failed",
            instances=[],
            error={
                "type": "https://etsi.org/sdg/oop/problems/zone-capacity-exceeded",
                "title": "Zone Capacity Exceeded",
                "status": 503,
                "detail": "no capacity",
            },
            correlation_id="corr-1",
            completed_at="2026-07-04T10:02:35+00:00",
        )

        await service.handle_completed(event)

        instance_a = await app_instance_repo.get_by_id(zone_a_id)
        instance_b = await app_instance_repo.get_by_id(zone_b_id)
        assert instance_a is not None
        assert instance_a.state == AppInstanceState.FAILED
        assert instance_b is not None
        assert instance_b.state == AppInstanceState.FAILED

    async def test_multi_zone_total_failure_respects_final_state_per_instance(
        self,
        service: EdgeApplicationManagementService,
        operation_repo: FakeOperationRepository,
        app_instance_repo: FakeAppInstanceRepository,
    ) -> None:
        """The staleness guard must still apply per-row when flipping all N
        instances, exactly as it already does on the single-zone path."""
        instantiating_id = UUID("9a3f1c22-0000-4000-8000-00000000000a")
        terminated_id = UUID("9a3f1c22-0000-4000-8000-00000000000b")
        await self._seed_pending_operation(operation_repo)
        await self._seed_instantiating_app_instance(app_instance_repo, instantiating_id)
        await self._seed_terminated_app_instance(app_instance_repo, terminated_id)
        event = SRMOperationCompleted(
            schema_version="1.0",
            operation_id=str(self.OPERATION_ID),
            status="failed",
            instances=[],
            error={"title": "Zone Capacity Exceeded", "status": 503},
            correlation_id="corr-1",
            completed_at="2026-07-04T10:02:35+00:00",
        )

        await service.handle_completed(event)

        flipped = await app_instance_repo.get_by_id(instantiating_id)
        unchanged = await app_instance_repo.get_by_id(terminated_id)
        assert flipped is not None
        assert flipped.state == AppInstanceState.FAILED
        assert unchanged is not None
        assert unchanged.state == AppInstanceState.TERMINATED

    async def test_failure_expressed_only_via_instances_is_processed(
        self,
        service: EdgeApplicationManagementService,