Loading src/open_exposure_gateway/adapters/database/repos/app_instances.py +5 −0 Original line number Diff line number Diff line Loading @@ -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, Loading src/open_exposure_gateway/application/services/edge_application_management_service.py +15 −7 Original line number Diff line number Diff line Loading @@ -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)) Loading @@ -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}) ) Loading src/open_exposure_gateway/ports/database/instances.py +4 −0 Original line number Diff line number Diff line Loading @@ -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 Loading tests/unit/fakes.py +7 −0 Original line number Diff line number Diff line Loading @@ -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( Loading tests/unit/test_eam_service.py +72 −0 Original line number Diff line number Diff line Loading @@ -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, Loading Loading
src/open_exposure_gateway/adapters/database/repos/app_instances.py +5 −0 Original line number Diff line number Diff line Loading @@ -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, Loading
src/open_exposure_gateway/application/services/edge_application_management_service.py +15 −7 Original line number Diff line number Diff line Loading @@ -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)) Loading @@ -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}) ) Loading
src/open_exposure_gateway/ports/database/instances.py +4 −0 Original line number Diff line number Diff line Loading @@ -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 Loading
tests/unit/fakes.py +7 −0 Original line number Diff line number Diff line Loading @@ -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( Loading
tests/unit/test_eam_service.py +72 −0 Original line number Diff line number Diff line Loading @@ -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, Loading