From f4669770cf597423ceb581032f3bffd2655eacae Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 6 May 2026 18:33:00 +0900 Subject: [PATCH 01/10] fix(BA-5963): resolve current_revision_id by explicit match, not list[0] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_convert_deployment_info_to_data` picked `info.model_revisions[0]` as the current revision. During a rolling update the list contains both current and deploying revisions, and PostgreSQL returned them in undefined order because `EndpointRow.revisions` had no `order_by`, so `current_revision_id` in REST/GraphQL responses could be reported as the deploying revision id — or as null when downstream adapters derived it from `data.revision.id`. Match the spec by `info.current_revision_id` directly, and add `order_by` on the `revisions` relationship for deterministic iteration in any other caller. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai/backend/manager/models/endpoint/row.py | 1 + .../manager/services/deployment/service.py | 24 ++++++++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/ai/backend/manager/models/endpoint/row.py b/src/ai/backend/manager/models/endpoint/row.py index 1c4e6f2ce0b..5eeafc2084a 100644 --- a/src/ai/backend/manager/models/endpoint/row.py +++ b/src/ai/backend/manager/models/endpoint/row.py @@ -291,6 +291,7 @@ class EndpointRow(Base): # type: ignore[misc] "DeploymentRevisionRow", back_populates="endpoint_row", primaryjoin=_get_endpoint_revisions_join_condition, + order_by="DeploymentRevisionRow.revision_number", ) auto_scaling_policy: Mapped[DeploymentAutoScalingPolicyRow | None] = relationship( diff --git a/src/ai/backend/manager/services/deployment/service.py b/src/ai/backend/manager/services/deployment/service.py index f8aa330af75..a261096f1a9 100644 --- a/src/ai/backend/manager/services/deployment/service.py +++ b/src/ai/backend/manager/services/deployment/service.py @@ -233,10 +233,28 @@ def _convert_deployment_info_to_data(info: DeploymentInfo) -> ModelDeploymentDat Note: Some fields are set to defaults as DeploymentInfo doesn't have all the data. """ - # Map revision if available + # Resolve the revision spec for the *current* revision specifically. + # ``info.model_revisions`` may also contain the deploying revision during a + # rolling update, and PostgreSQL returns those rows in undefined order, so + # picking ``model_revisions[0]`` would non-deterministically expose the + # deploying revision under ``current_revision_id``. revision: ModelRevisionData | None = None - if info.model_revisions: - rev = info.model_revisions[0] + rev: ModelRevisionSpec | None = None + if info.current_revision_id is not None: + rev = next( + (r for r in info.model_revisions if r.revision_id == info.current_revision_id), + None, + ) + if rev is None: + log.warning( + "Deployment {} has current_revision_id {} but no matching " + "ModelRevisionSpec was found in DeploymentInfo.model_revisions; " + "current_revision will be reported as null. This usually means " + "EndpointRow.revisions was not eagerly loaded by the caller.", + info.id, + info.current_revision_id, + ) + if rev is not None: if rev.revision_id is None: raise ValueError(f"ModelRevisionSpec has no revision_id for deployment {info.id}") revision = ModelRevisionData( From a7ad0d69b109b57e2bf009218884dbcaf2755fb6 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 6 May 2026 18:33:26 +0900 Subject: [PATCH 02/10] chore: add news fragment for BA-5963 fix Co-Authored-By: Claude Opus 4.7 (1M context) --- changes/11494.fix.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changes/11494.fix.md diff --git a/changes/11494.fix.md b/changes/11494.fix.md new file mode 100644 index 00000000000..c3fa0824bbd --- /dev/null +++ b/changes/11494.fix.md @@ -0,0 +1 @@ +Stop reporting the deploying revision id (or `null`) under `current_revision_id` on REST/GraphQL deployment responses during a rolling update. From ec9d7f81ea046b726df0acb59e91a3f7ed308749 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Wed, 6 May 2026 18:52:53 +0900 Subject: [PATCH 03/10] fix(BA-5963): expose current_revision_id directly on ModelDeploymentData MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stop deriving the API's `current_revision_id` from `data.revision.id`, which fell through to `null` whenever the matching `ModelRevisionSpec` could not be resolved (e.g. dangling `endpoints.current_revision` pointing at a removed `deployment_revisions` row — a real path observed in the field after replica/revision cleanup). Carry the column value through the data layer: `ModelDeploymentData` now has its own `current_revision_id`, populated from `info.current_revision_id` in the service layer, and the GraphQL/REST v2 adapter reads it directly. Identity is now decoupled from the spec, so the API mirrors the DB column even when the spec lookup fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai/backend/manager/api/adapters/deployment/adapter.py | 2 +- src/ai/backend/manager/data/deployment/types.py | 5 +++++ src/ai/backend/manager/services/deployment/service.py | 1 + 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ai/backend/manager/api/adapters/deployment/adapter.py b/src/ai/backend/manager/api/adapters/deployment/adapter.py index e381b1635fa..9508752efc6 100644 --- a/src/ai/backend/manager/api/adapters/deployment/adapter.py +++ b/src/ai/backend/manager/api/adapters/deployment/adapter.py @@ -2191,7 +2191,7 @@ def _deployment_data_to_dto(data: ModelDeploymentData) -> DeploymentNode: created_user_id=data.created_user_id, options=deployment_options_to_info(data.options), scaling_state=data.scaling_state, - current_revision_id=data.revision.id if data.revision is not None else None, + current_revision_id=data.current_revision_id, deploying_revision_id=data.deploying_revision_id, policy=policy_info, ) diff --git a/src/ai/backend/manager/data/deployment/types.py b/src/ai/backend/manager/data/deployment/types.py index cdd520a9484..b214d361f61 100644 --- a/src/ai/backend/manager/data/deployment/types.py +++ b/src/ai/backend/manager/data/deployment/types.py @@ -959,6 +959,11 @@ class ModelDeploymentData: metadata: ModelDeploymentMetadataInfo network_access: DeploymentNetworkSpec revision: ModelRevisionData | None + # Identity of the active revision, mirrored directly from + # ``endpoints.current_revision``. Decoupled from ``revision`` so the API + # can surface the DB-truth ID even if the matching ``ModelRevisionSpec`` + # is missing (e.g. dangling reference after a revision row was removed). + current_revision_id: DeploymentRevisionID | None deploying_revision_id: DeploymentRevisionID | None revision_history_ids: list[DeploymentRevisionID] scaling_rule_ids: list[UUID] diff --git a/src/ai/backend/manager/services/deployment/service.py b/src/ai/backend/manager/services/deployment/service.py index a261096f1a9..5f7d7ae0ba6 100644 --- a/src/ai/backend/manager/services/deployment/service.py +++ b/src/ai/backend/manager/services/deployment/service.py @@ -301,6 +301,7 @@ def _convert_deployment_info_to_data(info: DeploymentInfo) -> ModelDeploymentDat network_access=info.network, revision_history_ids=[info.current_revision_id] if info.current_revision_id else [], revision=revision, + current_revision_id=info.current_revision_id, deploying_revision_id=info.deploying_revision_id, scaling_rule_ids=[], # Not available in DeploymentInfo replica_state=ReplicaStateData( From 813c07ec46188c694534bd81260e5a45cc5cf644 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 13:50:33 +0900 Subject: [PATCH 04/10] fix(BA-5963): raise dangling current_revision_id log to error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unmatched-revision branch is reachable only via a caller bug (forgot to ``selectinload(EndpointRow.revisions)``) or DB integrity loss (the ``endpoints.current_revision`` pointer is set but the matching ``deployment_revisions`` row is gone). Both states are abnormal, yet the previous ``log.warning`` is easily missed in dashboards/alert rules — the response only surfaces a partial deployment with ``revision: null``, indistinguishable from an intended null. Lift the severity to ``error`` so monitoring catches the regression promptly without changing the graceful-degradation behaviour itself. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai/backend/manager/services/deployment/service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/backend/manager/services/deployment/service.py b/src/ai/backend/manager/services/deployment/service.py index 5f7d7ae0ba6..799856208ea 100644 --- a/src/ai/backend/manager/services/deployment/service.py +++ b/src/ai/backend/manager/services/deployment/service.py @@ -246,7 +246,7 @@ def _convert_deployment_info_to_data(info: DeploymentInfo) -> ModelDeploymentDat None, ) if rev is None: - log.warning( + log.error( "Deployment {} has current_revision_id {} but no matching " "ModelRevisionSpec was found in DeploymentInfo.model_revisions; " "current_revision will be reported as null. This usually means " From 31e58914a095afc0629e70bb2122622ca1445dfb Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 13:50:42 +0900 Subject: [PATCH 05/10] perf(BA-5963): order endpoint.revisions by revision_number desc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All current readers iterate ``EndpointRow.revisions`` looking for a specific revision id (current, deploying); no caller relies on the sort direction for correctness today. Descending order lets the more-frequently-requested recent revisions match earlier on average and — should anyone reintroduce a ``revisions[0]`` reading (the anti-pattern this PR exists to fix) — points at the latest spec instead of the oldest, narrowing the blast radius if it slips back in. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai/backend/manager/models/endpoint/row.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ai/backend/manager/models/endpoint/row.py b/src/ai/backend/manager/models/endpoint/row.py index 5eeafc2084a..53422f308bc 100644 --- a/src/ai/backend/manager/models/endpoint/row.py +++ b/src/ai/backend/manager/models/endpoint/row.py @@ -291,7 +291,7 @@ class EndpointRow(Base): # type: ignore[misc] "DeploymentRevisionRow", back_populates="endpoint_row", primaryjoin=_get_endpoint_revisions_join_condition, - order_by="DeploymentRevisionRow.revision_number", + order_by="DeploymentRevisionRow.revision_number.desc()", ) auto_scaling_policy: Mapped[DeploymentAutoScalingPolicyRow | None] = relationship( From ce5b5db231a45663c1532e5bbd30034c274a425e Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 13:55:42 +0900 Subject: [PATCH 06/10] test(BA-5963): add regression test for explicit current_revision_id match MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin the contract enforced by ``_convert_deployment_info_to_data``: when ``DeploymentInfo.model_revisions`` carries both the current and the deploying revision (typical during a rolling update), the conversion must resolve the current revision by an explicit ``current_revision_id`` match — not by ``model_revisions[0]``. The test uses adversarial ordering (deploying revision at index 0) so the previous list-index implementation would surface ``deploying_revision_id`` under ``current_revision_id`` and trip multiple assertions at once. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../deployment/test_deployment_service.py | 80 ++++++++++++++++++- 1 file changed, 79 insertions(+), 1 deletion(-) diff --git a/tests/unit/manager/services/deployment/test_deployment_service.py b/tests/unit/manager/services/deployment/test_deployment_service.py index 02531f3ddd7..4c7fa93abab 100644 --- a/tests/unit/manager/services/deployment/test_deployment_service.py +++ b/tests/unit/manager/services/deployment/test_deployment_service.py @@ -21,6 +21,7 @@ ) from ai.backend.common.dto.manager.v2.deployment.types import IntOrPercent from ai.backend.common.identifier.deployment import DeploymentID +from ai.backend.common.identifier.deployment_revision import DeploymentRevisionID from ai.backend.common.identifier.image import ImageID from ai.backend.common.identifier.runtime_variant import RuntimeVariantID from ai.backend.common.identifier.vfolder import VFolderUUID @@ -50,7 +51,9 @@ ExecutionSpec, ModelMountConfigData, ModelRevisionData, + ModelRevisionSpec, ModelRuntimeConfigData, + MountMetadata, ReplicaSpec, ResourceConfigData, ResourceSpec, @@ -76,7 +79,10 @@ AddModelRevisionAction, ) from ai.backend.manager.services.deployment.processors import DeploymentProcessors -from ai.backend.manager.services.deployment.service import DeploymentService +from ai.backend.manager.services.deployment.service import ( + DeploymentService, + _convert_deployment_info_to_data, +) from ai.backend.manager.sokovan.deployment import DeploymentController @@ -634,3 +640,75 @@ async def test_persists_coordinator_jwt_instead_of_random( creator = cast(RBACEntityCreator[object], repo_call.args[0]) spec = cast(EndpointTokenCreatorSpec, creator.spec) assert spec.token == sample_coordinator_jwt + + +class TestConvertDeploymentInfoToData: + """Regression test for ``_convert_deployment_info_to_data`` (BA-5963).""" + + def test_current_revision_resolved_by_id_match_not_list_order(self) -> None: + """During a rolling update, ``DeploymentInfo.model_revisions`` may carry + both the current and deploying revisions. PostgreSQL returns those rows + in undefined order, so the conversion must resolve the current revision + by an explicit ``current_revision_id`` match — picking + ``model_revisions[0]`` non-deterministically leaks the deploying + revision into ``current_revision_id`` on the public response (BA-5963). + """ + deploying_revision_id = DeploymentRevisionID(uuid.uuid4()) + current_revision_id = DeploymentRevisionID(uuid.uuid4()) + + def make_spec(revision_id: DeploymentRevisionID) -> ModelRevisionSpec: + return ModelRevisionSpec( + revision_id=revision_id, + image_id=ImageID(uuid.uuid4()), + resource_spec=ResourceSpec( + cluster_mode=ClusterMode.SINGLE_NODE, + cluster_size=1, + resource_slots={"cpu": "1"}, + ), + mounts=MountMetadata( + model_vfolder_id=VFolderUUID(uuid.uuid4()), + model_definition_path="model-definition.yaml", + model_mount_destination="/models", + extra_mounts=[], + ), + execution=ExecutionSpec( + runtime_variant_id=RuntimeVariantID(uuid.uuid4()), + ), + ) + + deploying_spec = make_spec(deploying_revision_id) + current_spec = make_spec(current_revision_id) + + # Adversarial ordering: deploying revision listed first. + deployment_info = DeploymentInfo( + id=DeploymentID(uuid.uuid4()), + metadata=DeploymentMetadata( + name="ba5963-test", + domain="default", + project=uuid.uuid4(), + resource_group="default", + created_user=uuid.uuid4(), + session_owner=uuid.uuid4(), + created_at=datetime(2024, 1, 1, tzinfo=UTC), + revision_history_limit=10, + ), + state=DeploymentState( + lifecycle=EndpointLifecycle.DEPLOYING, + scaling_state=ScalingState.STABLE, + retry_count=0, + ), + replica_spec=ReplicaSpec(replica_count=1), + network=DeploymentNetworkSpec(open_to_public=False), + model_revisions=[deploying_spec, current_spec], + options=DeploymentOptions(), + current_revision_id=current_revision_id, + deploying_revision_id=deploying_revision_id, + ) + + deployment_data = _convert_deployment_info_to_data(deployment_info) + + assert deployment_data.current_revision_id == current_revision_id + assert deployment_data.deploying_revision_id == deploying_revision_id + assert deployment_data.current_revision_id != deployment_data.deploying_revision_id + assert deployment_data.revision is not None + assert deployment_data.revision.id == current_revision_id From 11dbca7f383fb2665b616eeaee2ca1a33dd5da59 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 14:01:07 +0900 Subject: [PATCH 07/10] chore(BA-5963): address self-review comments - service.py: drop the inline narration above the revision lookup; the same context already lives in the fix commit message. - test_deployment_service.py: shorten the verbose docstring, hoist ``make_revision_spec`` into a pytest fixture instead of a closure, and remove the redundant "adversarial ordering" inline comment (the spec/id wiring expresses the same intent on its own). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../manager/services/deployment/service.py | 5 --- .../deployment/test_deployment_service.py | 40 +++++++++---------- 2 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/ai/backend/manager/services/deployment/service.py b/src/ai/backend/manager/services/deployment/service.py index 799856208ea..77ae25b2cdb 100644 --- a/src/ai/backend/manager/services/deployment/service.py +++ b/src/ai/backend/manager/services/deployment/service.py @@ -233,11 +233,6 @@ def _convert_deployment_info_to_data(info: DeploymentInfo) -> ModelDeploymentDat Note: Some fields are set to defaults as DeploymentInfo doesn't have all the data. """ - # Resolve the revision spec for the *current* revision specifically. - # ``info.model_revisions`` may also contain the deploying revision during a - # rolling update, and PostgreSQL returns those rows in undefined order, so - # picking ``model_revisions[0]`` would non-deterministically expose the - # deploying revision under ``current_revision_id``. revision: ModelRevisionData | None = None rev: ModelRevisionSpec | None = None if info.current_revision_id is not None: diff --git a/tests/unit/manager/services/deployment/test_deployment_service.py b/tests/unit/manager/services/deployment/test_deployment_service.py index 4c7fa93abab..6fc9d4c58a2 100644 --- a/tests/unit/manager/services/deployment/test_deployment_service.py +++ b/tests/unit/manager/services/deployment/test_deployment_service.py @@ -7,6 +7,7 @@ from __future__ import annotations import uuid +from collections.abc import Callable from datetime import UTC, datetime from typing import cast from unittest.mock import AsyncMock, MagicMock @@ -645,20 +646,11 @@ async def test_persists_coordinator_jwt_instead_of_random( class TestConvertDeploymentInfoToData: """Regression test for ``_convert_deployment_info_to_data`` (BA-5963).""" - def test_current_revision_resolved_by_id_match_not_list_order(self) -> None: - """During a rolling update, ``DeploymentInfo.model_revisions`` may carry - both the current and deploying revisions. PostgreSQL returns those rows - in undefined order, so the conversion must resolve the current revision - by an explicit ``current_revision_id`` match — picking - ``model_revisions[0]`` non-deterministically leaks the deploying - revision into ``current_revision_id`` on the public response (BA-5963). - """ - deploying_revision_id = DeploymentRevisionID(uuid.uuid4()) - current_revision_id = DeploymentRevisionID(uuid.uuid4()) - - def make_spec(revision_id: DeploymentRevisionID) -> ModelRevisionSpec: + @pytest.fixture + def make_revision_spec(self) -> Callable[[], ModelRevisionSpec]: + def make() -> ModelRevisionSpec: return ModelRevisionSpec( - revision_id=revision_id, + revision_id=DeploymentRevisionID(uuid.uuid4()), image_id=ImageID(uuid.uuid4()), resource_spec=ResourceSpec( cluster_mode=ClusterMode.SINGLE_NODE, @@ -676,10 +668,16 @@ def make_spec(revision_id: DeploymentRevisionID) -> ModelRevisionSpec: ), ) - deploying_spec = make_spec(deploying_revision_id) - current_spec = make_spec(current_revision_id) + return make + + def test_current_revision_resolved_by_id_match_not_list_order( + self, + make_revision_spec: Callable[[], ModelRevisionSpec], + ) -> None: + """Pin: revision lookup must use explicit ``current_revision_id``, not list[0].""" + deploying_spec = make_revision_spec() + current_spec = make_revision_spec() - # Adversarial ordering: deploying revision listed first. deployment_info = DeploymentInfo( id=DeploymentID(uuid.uuid4()), metadata=DeploymentMetadata( @@ -701,14 +699,14 @@ def make_spec(revision_id: DeploymentRevisionID) -> ModelRevisionSpec: network=DeploymentNetworkSpec(open_to_public=False), model_revisions=[deploying_spec, current_spec], options=DeploymentOptions(), - current_revision_id=current_revision_id, - deploying_revision_id=deploying_revision_id, + current_revision_id=current_spec.revision_id, + deploying_revision_id=deploying_spec.revision_id, ) deployment_data = _convert_deployment_info_to_data(deployment_info) - assert deployment_data.current_revision_id == current_revision_id - assert deployment_data.deploying_revision_id == deploying_revision_id + assert deployment_data.current_revision_id == current_spec.revision_id + assert deployment_data.deploying_revision_id == deploying_spec.revision_id assert deployment_data.current_revision_id != deployment_data.deploying_revision_id assert deployment_data.revision is not None - assert deployment_data.revision.id == current_revision_id + assert deployment_data.revision.id == current_spec.revision_id From 29afe4719f8a05b7bf56a5a8a1eee00af1256eb4 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 14:13:39 +0900 Subject: [PATCH 08/10] chore(BA-5963): tighten field comment and rewrite news fragment - ``ModelDeploymentData.current_revision_id``: drop the inline rationale; the field name on its own already reads as the DB-column identity, and the WHY is captured in the fix commit message rather than next to the field. - ``changes/11494.fix.md``: rewrite as a single user-facing sentence about the actual headline change (correct ``current_revision_id`` reporting), not the implementation detail of "list[0]". Co-Authored-By: Claude Opus 4.7 (1M context) --- changes/11494.fix.md | 2 +- src/ai/backend/manager/data/deployment/types.py | 4 ---- 2 files changed, 1 insertion(+), 5 deletions(-) diff --git a/changes/11494.fix.md b/changes/11494.fix.md index c3fa0824bbd..7155c029049 100644 --- a/changes/11494.fix.md +++ b/changes/11494.fix.md @@ -1 +1 @@ -Stop reporting the deploying revision id (or `null`) under `current_revision_id` on REST/GraphQL deployment responses during a rolling update. +Report `current_revision_id` correctly on deployment responses during rolling updates. diff --git a/src/ai/backend/manager/data/deployment/types.py b/src/ai/backend/manager/data/deployment/types.py index b214d361f61..ac072072032 100644 --- a/src/ai/backend/manager/data/deployment/types.py +++ b/src/ai/backend/manager/data/deployment/types.py @@ -959,10 +959,6 @@ class ModelDeploymentData: metadata: ModelDeploymentMetadataInfo network_access: DeploymentNetworkSpec revision: ModelRevisionData | None - # Identity of the active revision, mirrored directly from - # ``endpoints.current_revision``. Decoupled from ``revision`` so the API - # can surface the DB-truth ID even if the matching ``ModelRevisionSpec`` - # is missing (e.g. dangling reference after a revision row was removed). current_revision_id: DeploymentRevisionID | None deploying_revision_id: DeploymentRevisionID | None revision_history_ids: list[DeploymentRevisionID] From f6dd4dcadcf53c129ddd80fdfa8a66d44bd83812 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 14:36:39 +0900 Subject: [PATCH 09/10] fix(BA-5963): drop model_revisions[0] from legacy REST service handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two call sites in ``api/rest/service/handler.py`` (the GET ``ServeInfo`` projection and the create-legacy-deployment response shaping) were reading ``deployment_info.model_revisions[0]`` as the active revision spec. With ``EndpointRow.revisions`` now sorted ``revision_number desc`` for cheaper current-revision lookup, ``[0]`` is the deploying (newer) revision during a rolling update, so those handlers would surface mounts / model_definition_path / runtime_variant_id from the not-yet-active spec. Replace both with a small helper that resolves the active revision by explicit id (``current_revision_id`` first, ``deploying_revision_id`` as the bootstrap fallback before promotion) — the same contract the fixed ``_convert_deployment_info_to_data`` already follows. Caught by Copilot review on PR #11494. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../backend/manager/api/rest/service/handler.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ai/backend/manager/api/rest/service/handler.py b/src/ai/backend/manager/api/rest/service/handler.py index de1bf1bc89b..e93f1ea43a3 100644 --- a/src/ai/backend/manager/api/rest/service/handler.py +++ b/src/ai/backend/manager/api/rest/service/handler.py @@ -68,6 +68,7 @@ DeploymentNetworkSpec, ExecutionSpec, ImageIdentifierDraft, + ModelRevisionSpec, ModelRevisionSpecDraft, MountMetadata, ReplicaSpec, @@ -167,6 +168,14 @@ def _serve_info_from_dto(dto: ServiceInfo, runtime_variant_name: RuntimeVariant) ) +def _resolve_active_revision_spec(info: DeploymentInfo) -> ModelRevisionSpec | None: + """Resolve the active revision spec by id (current first, then deploying).""" + target_id = info.current_revision_id or info.deploying_revision_id + if target_id is None: + return None + return next((r for r in info.model_revisions if r.revision_id == target_id), None) + + def _serve_info_from_deployment_info( deployment_info: DeploymentInfo, runtime_variant_name: RuntimeVariant, @@ -177,7 +186,7 @@ def _serve_info_from_deployment_info( active revision's ``runtime_variant_id`` (internal data types are id-only; the legacy REST response still exposes the name string). """ - model_revision = deployment_info.model_revisions[0] if deployment_info.model_revisions else None + model_revision = _resolve_active_revision_spec(deployment_info) return ServeInfoModel( endpoint_id=deployment_info.id, @@ -378,9 +387,7 @@ async def create(self, body: BodyParam[NewServiceRequestModel], req: RequestCtx) await self._deployment.create_legacy_deployment.wait_for_complete(deployment_action) ) deployment_info = deployment_result.data - model_revision = ( - deployment_info.model_revisions[0] if deployment_info.model_revisions else None - ) + model_revision = _resolve_active_revision_spec(deployment_info) if model_revision is None: raise RuntimeVariantNotFound() runtime_variant_name = await self._resolve_runtime_variant_name( From cf5aa3f0ac8e52a86b956e3511bba737e4bb1f01 Mon Sep 17 00:00:00 2001 From: jopemachine Date: Fri, 8 May 2026 14:46:36 +0900 Subject: [PATCH 10/10] refactor(BA-5963): rename helper to _resolve_target_revision_spec Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ai/backend/manager/api/rest/service/handler.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/ai/backend/manager/api/rest/service/handler.py b/src/ai/backend/manager/api/rest/service/handler.py index e93f1ea43a3..95b881ff049 100644 --- a/src/ai/backend/manager/api/rest/service/handler.py +++ b/src/ai/backend/manager/api/rest/service/handler.py @@ -168,8 +168,8 @@ def _serve_info_from_dto(dto: ServiceInfo, runtime_variant_name: RuntimeVariant) ) -def _resolve_active_revision_spec(info: DeploymentInfo) -> ModelRevisionSpec | None: - """Resolve the active revision spec by id (current first, then deploying).""" +def _resolve_target_revision_spec(info: DeploymentInfo) -> ModelRevisionSpec | None: + """Resolve the target revision spec by id (current first, then deploying).""" target_id = info.current_revision_id or info.deploying_revision_id if target_id is None: return None @@ -186,7 +186,7 @@ def _serve_info_from_deployment_info( active revision's ``runtime_variant_id`` (internal data types are id-only; the legacy REST response still exposes the name string). """ - model_revision = _resolve_active_revision_spec(deployment_info) + model_revision = _resolve_target_revision_spec(deployment_info) return ServeInfoModel( endpoint_id=deployment_info.id, @@ -387,7 +387,7 @@ async def create(self, body: BodyParam[NewServiceRequestModel], req: RequestCtx) await self._deployment.create_legacy_deployment.wait_for_complete(deployment_action) ) deployment_info = deployment_result.data - model_revision = _resolve_active_revision_spec(deployment_info) + model_revision = _resolve_target_revision_spec(deployment_info) if model_revision is None: raise RuntimeVariantNotFound() runtime_variant_name = await self._resolve_runtime_variant_name(