Loading src/open_exposure_gateway/application/services/edge_application_management_service.py +10 −6 Original line number Diff line number Diff line Loading @@ -128,6 +128,11 @@ def _is_final(app_instance: AppInstance) -> bool: return app_instance.state == AppInstanceState.TERMINATED def _deployment_is_final(app_deployment: AppDeployment) -> bool: """Whether no completion may move this deployment rollup any further.""" return app_deployment.state == AppDeploymentState.TERMINATED def _log_stale_completion(app_instance: AppInstance, operation_id: UUID) -> None: logger.info( "stale_completion_ignored_for_final_app_instance", Loading Loading @@ -867,14 +872,13 @@ class EdgeApplicationManagementService: # Optional cache rollup, multi-zone deployments only (persistence-model.md). # A single-zone /appinstances operation_id has no matching row, so this # is a soft no-op for the existing path -- same optional-dependency # pattern as the callback repos below. DELETE /deployments doesn't # exist yet, so app_deployments.state can only be INSTANTIATING here; # once it does, this will need the same _is_final-style guard the # instances above already have, to avoid reviving a terminated # deployment on a stale redelivery. # pattern as the callback repos below. DELETE /deployments doesn't exist # yet, so app_deployments.state can only be INSTANTIATING here, but the # _is_final guard mirrors the instances above so a stale redelivery # can't revive a terminated deployment once DELETE lands. if self._app_deployment_repo is not None: app_deployment = await self._app_deployment_repo.get_by_operation_id(operation_id) if app_deployment is not None: if app_deployment is not None and not _deployment_is_final(app_deployment): await self._app_deployment_repo.save( app_deployment.model_copy( update={"state": _APP_DEPLOYMENT_COMPLETION_STATE_MAP[status]} Loading tests/unit/test_eam_service.py +47 −0 Original line number Diff line number Diff line Loading @@ -2399,6 +2399,53 @@ class TestHandleCompleted: assert updated is not None assert updated.state == AppDeploymentState.FAILED async def _seed_terminated_app_deployment( self, app_deployment_repo: FakeAppDeploymentRepository, app_deployment_id: UUID ) -> None: await app_deployment_repo.save( AppDeployment( app_deployment_id=app_deployment_id, operation_id=self.OPERATION_ID, app_registration_id=uuid4(), app_deployment_name="video_analytics_eu", edge_cloud_zones=[ZONE_ID], state=AppDeploymentState.TERMINATED, ) ) async def test_late_deploy_completion_does_not_revive_terminated_deployment( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository, app_instance_repo: FakeAppInstanceRepository, app_deployment_repo: FakeAppDeploymentRepository, ) -> None: """A redelivered deploy completion must not roll a deployment that has since reached TERMINATED back to a live state -- the same staleness guard the per-instance rollups already carry.""" deployment_id = uuid4() await self._seed_pending_operation(operation_repo, OperationType.DEPLOY) await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) await self._seed_terminated_app_deployment(app_deployment_repo, deployment_id) event = SRMOperationCompleted( schema_version="1.0", operation_id=str(self.OPERATION_ID), status="completed", instances=[ SRMCompletedInstance( service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" ) ], correlation_id="corr-1", completed_at="2026-07-04T10:02:35+00:00", ) await service.handle_completed(event) unchanged = await app_deployment_repo.get_by_id(deployment_id) assert unchanged is not None assert unchanged.state == AppDeploymentState.TERMINATED async def test_single_zone_operation_leaves_app_deployments_untouched( self, service: EdgeApplicationManagementService, Loading Loading
src/open_exposure_gateway/application/services/edge_application_management_service.py +10 −6 Original line number Diff line number Diff line Loading @@ -128,6 +128,11 @@ def _is_final(app_instance: AppInstance) -> bool: return app_instance.state == AppInstanceState.TERMINATED def _deployment_is_final(app_deployment: AppDeployment) -> bool: """Whether no completion may move this deployment rollup any further.""" return app_deployment.state == AppDeploymentState.TERMINATED def _log_stale_completion(app_instance: AppInstance, operation_id: UUID) -> None: logger.info( "stale_completion_ignored_for_final_app_instance", Loading Loading @@ -867,14 +872,13 @@ class EdgeApplicationManagementService: # Optional cache rollup, multi-zone deployments only (persistence-model.md). # A single-zone /appinstances operation_id has no matching row, so this # is a soft no-op for the existing path -- same optional-dependency # pattern as the callback repos below. DELETE /deployments doesn't # exist yet, so app_deployments.state can only be INSTANTIATING here; # once it does, this will need the same _is_final-style guard the # instances above already have, to avoid reviving a terminated # deployment on a stale redelivery. # pattern as the callback repos below. DELETE /deployments doesn't exist # yet, so app_deployments.state can only be INSTANTIATING here, but the # _is_final guard mirrors the instances above so a stale redelivery # can't revive a terminated deployment once DELETE lands. if self._app_deployment_repo is not None: app_deployment = await self._app_deployment_repo.get_by_operation_id(operation_id) if app_deployment is not None: if app_deployment is not None and not _deployment_is_final(app_deployment): await self._app_deployment_repo.save( app_deployment.model_copy( update={"state": _APP_DEPLOYMENT_COMPLETION_STATE_MAP[status]} Loading
tests/unit/test_eam_service.py +47 −0 Original line number Diff line number Diff line Loading @@ -2399,6 +2399,53 @@ class TestHandleCompleted: assert updated is not None assert updated.state == AppDeploymentState.FAILED async def _seed_terminated_app_deployment( self, app_deployment_repo: FakeAppDeploymentRepository, app_deployment_id: UUID ) -> None: await app_deployment_repo.save( AppDeployment( app_deployment_id=app_deployment_id, operation_id=self.OPERATION_ID, app_registration_id=uuid4(), app_deployment_name="video_analytics_eu", edge_cloud_zones=[ZONE_ID], state=AppDeploymentState.TERMINATED, ) ) async def test_late_deploy_completion_does_not_revive_terminated_deployment( self, service: EdgeApplicationManagementService, operation_repo: FakeOperationRepository, app_instance_repo: FakeAppInstanceRepository, app_deployment_repo: FakeAppDeploymentRepository, ) -> None: """A redelivered deploy completion must not roll a deployment that has since reached TERMINATED back to a live state -- the same staleness guard the per-instance rollups already carry.""" deployment_id = uuid4() await self._seed_pending_operation(operation_repo, OperationType.DEPLOY) await self._seed_instantiating_app_instance(app_instance_repo, INSTANCE_ID) await self._seed_terminated_app_deployment(app_deployment_repo, deployment_id) event = SRMOperationCompleted( schema_version="1.0", operation_id=str(self.OPERATION_ID), status="completed", instances=[ SRMCompletedInstance( service_instance_id=str(INSTANCE_ID), zone_id=str(ZONE_ID), status="completed" ) ], correlation_id="corr-1", completed_at="2026-07-04T10:02:35+00:00", ) await service.handle_completed(event) unchanged = await app_deployment_repo.get_by_id(deployment_id) assert unchanged is not None assert unchanged.state == AppDeploymentState.TERMINATED async def test_single_zone_operation_leaves_app_deployments_untouched( self, service: EdgeApplicationManagementService, Loading