Skip to content

Commit d9b342f

Browse files
jopemachinelablup-octodogclaude
authored
feat(BA-5982): expose revision_number, separate Spec/Data on DeploymentInfo read path (#11529)
Co-authored-by: octodog <mu001@lablup.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 16f4bc6 commit d9b342f

47 files changed

Lines changed: 628 additions & 468 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

changes/11529.feature.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Expose the per-deployment `revision_number` on `ModelRevision` GraphQL nodes and REST v2 revision responses so clients can render "Revision #N" labels and order revisions without an extra round-trip.

docs/manager/graphql-reference/supergraph.graphql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9635,6 +9635,11 @@ type ModelRevision implements Node
96359635
id: ID!
96369636
imageId: ID!
96379637

9638+
"""
9639+
Added in UNRELEASED. Per-deployment sequential revision number assigned at insert time (UNIQUE per deployment). Use this to surface 'Revision #N' labels and to order revisions client-side.
9640+
"""
9641+
revisionNumber: Int!
9642+
96389643
"""Cluster configuration for replica distribution."""
96399644
clusterConfig: ClusterConfig!
96409645

docs/manager/graphql-reference/v2-schema.graphql

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6404,6 +6404,11 @@ type ModelRevision implements Node {
64046404
id: ID!
64056405
imageId: ID!
64066406

6407+
"""
6408+
Added in UNRELEASED. Per-deployment sequential revision number assigned at insert time (UNIQUE per deployment). Use this to surface 'Revision #N' labels and to order revisions client-side.
6409+
"""
6410+
revisionNumber: Int!
6411+
64076412
"""Cluster configuration for replica distribution."""
64086413
clusterConfig: ClusterConfig!
64096414

src/ai/backend/common/dto/manager/v2/deployment/response.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,13 @@ class RevisionNode(BaseResponseModel):
100100
"""Node model representing a deployment revision."""
101101

102102
id: UUID = Field(description="Revision ID")
103+
revision_number: int = Field(
104+
description=(
105+
"Per-deployment sequential revision number assigned at insert "
106+
"time (UNIQUE per deployment). Stable across the lifetime of the "
107+
"row and suitable for surfacing 'Revision #N' labels."
108+
),
109+
)
103110
# ``image_id`` is null when the referenced image row has been deleted
104111
# (``deployment_revisions.image`` SET NULL FK); the revision is kept for
105112
# history but cannot be redeployed in that state.

src/ai/backend/manager/api/adapters/deployment/adapter.py

Lines changed: 21 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@
136136
ResourceOptsInfoDTO,
137137
)
138138
from ai.backend.common.identifier.deployment import DeploymentID
139+
from ai.backend.common.identifier.deployment_revision import DeploymentRevisionID
139140
from ai.backend.manager.api.adapter_options.deployment.options import (
140141
deployment_options_from_input,
141142
deployment_options_to_info,
@@ -666,24 +667,24 @@ def _by_project_id() -> sa.sql.expression.ColumnElement[bool]:
666667
has_previous_page=action_result.has_previous_page,
667668
)
668669

669-
async def get(self, deployment_id: UUID) -> DeploymentNode:
670+
async def get(self, deployment_id: DeploymentID) -> DeploymentNode:
670671
"""Retrieve a single deployment by ID."""
671672
action_result = await self._processors.deployment.get_deployment_by_id.wait_for_complete(
672673
GetDeploymentByIdAction(deployment_id=deployment_id)
673674
)
674675
return self._deployment_data_to_dto(action_result.data)
675676

676-
async def get_current_revision(self, deployment_id: UUID) -> RevisionNode:
677+
async def get_current_revision(self, deployment_id: DeploymentID) -> RevisionNode:
677678
"""Retrieve the current active revision of a deployment."""
678679
deployment = await self.get(deployment_id)
679680
if deployment.current_revision_id is None:
680681
raise DeploymentRevisionNotFound(f"Deployment {deployment_id} has no current revision")
681-
return await self.get_revision(deployment.current_revision_id)
682+
return await self.get_revision(DeploymentRevisionID(deployment.current_revision_id))
682683

683684
async def update(
684685
self,
685686
input: UpdateDeploymentInput,
686-
deployment_id: UUID,
687+
deployment_id: DeploymentID,
687688
) -> UpdateDeploymentPayload:
688689
"""Update deployment metadata and configuration."""
689690
metadata_spec: DeploymentMetadataUpdaterSpec | None = None
@@ -766,16 +767,16 @@ async def replace_options(
766767
async def sync_replicas(self, input: SyncReplicaInput) -> SyncReplicaPayload:
767768
"""Force sync replica information for a deployment."""
768769
await self._processors.deployment.sync_replicas.wait_for_complete(
769-
SyncReplicaAction(deployment_id=input.model_deployment_id)
770+
SyncReplicaAction(deployment_id=DeploymentID(input.model_deployment_id))
770771
)
771772
return SyncReplicaPayload(success=True)
772773

773774
async def activate_revision(self, input: ActivateRevisionInput) -> ActivateRevisionPayload:
774775
"""Activate a specific revision as the current revision."""
775776
action_result = await self._processors.deployment.activate_revision.wait_for_complete(
776777
ActivateRevisionAction(
777-
deployment_id=input.deployment_id,
778-
revision_id=input.revision_id,
778+
deployment_id=DeploymentID(input.deployment_id),
779+
revision_id=DeploymentRevisionID(input.revision_id),
779780
)
780781
)
781782
return ActivateRevisionPayload(
@@ -823,7 +824,7 @@ async def create_access_token(
823824
) -> CreateAccessTokenPayload:
824825
"""Create a new access token for a deployment."""
825826
creator = ModelDeploymentAccessTokenCreator(
826-
model_deployment_id=input.model_deployment_id,
827+
model_deployment_id=DeploymentID(input.model_deployment_id),
827828
expires_at=input.expires_at,
828829
)
829830
action_result = await self._processors.deployment.create_access_token.wait_for_complete(
@@ -1007,10 +1008,10 @@ async def bulk_delete_rules(
10071008
# Deployment policy operations
10081009
# ------------------------------------------------------------------
10091010

1010-
async def get_policy(self, deployment_id: UUID) -> GetDeploymentPolicyPayload:
1011+
async def get_policy(self, deployment_id: DeploymentID) -> GetDeploymentPolicyPayload:
10111012
"""Retrieve a deployment policy by deployment ID."""
10121013
action_result = await self._processors.deployment.get_deployment_policy.wait_for_complete(
1013-
GetDeploymentPolicyAction(deployment_id=DeploymentID(deployment_id))
1014+
GetDeploymentPolicyAction(deployment_id=deployment_id)
10141015
)
10151016
return GetDeploymentPolicyPayload(policy=self._policy_data_to_dto(action_result.data))
10161017

@@ -1115,14 +1116,14 @@ async def add_revision(
11151116
)
11161117
action_result = await self._processors.deployment.add_model_revision.wait_for_complete(
11171118
AddModelRevisionAction(
1118-
model_deployment_id=input.deployment_id,
1119+
model_deployment_id=DeploymentID(input.deployment_id),
11191120
adder=adder,
11201121
auto_activate=options.auto_activate,
11211122
)
11221123
)
11231124
return AddRevisionPayload(revision=self._revision_data_to_dto(action_result.revision))
11241125

1125-
async def get_revision(self, revision_id: UUID) -> RevisionNode:
1126+
async def get_revision(self, revision_id: DeploymentRevisionID) -> RevisionNode:
11261127
"""Retrieve a single revision by ID."""
11271128
action_result = await self._processors.deployment.get_revision_by_id.wait_for_complete(
11281129
GetRevisionByIdAction(revision_id=revision_id)
@@ -1168,7 +1169,7 @@ async def admin_search_revisions(
11681169

11691170
async def search_revision_resource_slots(
11701171
self,
1171-
revision_id: UUID,
1172+
revision_id: DeploymentRevisionID,
11721173
input: SearchAllocatedResourceSlotsInput,
11731174
) -> SearchAllocatedResourceSlotsPayload:
11741175
"""Search resource slots allocated to a deployment revision."""
@@ -1317,7 +1318,9 @@ async def batch_load_revisions_by_ids(
13171318
action_result = await self._processors.deployment.search_revisions.wait_for_complete(
13181319
SearchRevisionsAction(querier=querier)
13191320
)
1320-
revision_map = {data.id: self._revision_data_to_dto(data) for data in action_result.data}
1321+
revision_map: dict[uuid.UUID, RevisionNode] = {
1322+
data.id: self._revision_data_to_dto(data) for data in action_result.data
1323+
}
13211324
return [revision_map.get(revision_id) for revision_id in revision_ids]
13221325

13231326
async def batch_load_replicas_by_ids(
@@ -2005,7 +2008,7 @@ def _build_replica_querier(
20052008
def _build_revision_resource_slot_querier(
20062009
self,
20072010
input: SearchAllocatedResourceSlotsInput,
2008-
revision_id: UUID,
2011+
revision_id: DeploymentRevisionID,
20092012
) -> BatchQuerier:
20102013
conditions: list[QueryCondition] = [
20112014
RevisionResourceSlotConditions.by_revision_id(revision_id),
@@ -2215,6 +2218,7 @@ def _revision_data_to_dto(data: ModelRevisionData) -> RevisionNode:
22152218
)
22162219
return RevisionNode(
22172220
id=data.id,
2221+
revision_number=data.revision_number,
22182222
image_id=data.image_id,
22192223
cluster_config=ClusterConfigInfoDTO(
22202224
mode=data.cluster_config.mode.name,
@@ -2263,9 +2267,9 @@ def _revision_data_to_dto(data: ModelRevisionData) -> RevisionNode:
22632267
mount_destination=m.mount_destination,
22642268
mount_perm=m.mount_perm,
22652269
)
2266-
for m in data.extra_vfolder_mounts
2270+
for m in data.model_mount_config.extra_mounts
22672271
],
2268-
revision_preset_id=data.revision_preset_id,
2272+
revision_preset_id=data.preset.preset_id,
22692273
)
22702274

22712275
@staticmethod

src/ai/backend/manager/api/gql/deployment/resolver/deployment.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
AdminSearchDeploymentsInput,
1414
ReplaceDeploymentOptionsInput,
1515
)
16+
from ai.backend.common.identifier.deployment import DeploymentID
1617
from ai.backend.common.meta import NEXT_RELEASE_VERSION
1718
from ai.backend.manager.api.gql.base import encode_cursor, resolve_global_id
1819
from ai.backend.manager.api.gql.decorators import (
@@ -196,7 +197,7 @@ async def my_deployments(
196197
async def deployment(id: ID, info: Info[StrawberryGQLContext]) -> ModelDeployment | None:
197198
"""Get a specific deployment by ID."""
198199
_, deployment_id = resolve_global_id(id)
199-
node = await info.context.adapters.deployment.get(UUID(deployment_id))
200+
node = await info.context.adapters.deployment.get(DeploymentID(UUID(deployment_id)))
200201
return ModelDeployment.from_pydantic(node)
201202

202203

@@ -220,7 +221,9 @@ async def update_model_deployment(
220221
input: UpdateDeploymentInput, info: Info[StrawberryGQLContext]
221222
) -> UpdateDeploymentPayload | None:
222223
"""Update an existing model deployment."""
223-
payload = await info.context.adapters.deployment.update(input.to_pydantic(), UUID(input.id))
224+
payload = await info.context.adapters.deployment.update(
225+
input.to_pydantic(), DeploymentID(UUID(input.id))
226+
)
224227
return UpdateDeploymentPayload(deployment=ModelDeployment.from_pydantic(payload.deployment))
225228

226229

src/ai/backend/manager/api/gql/deployment/resolver/revision.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from ai.backend.common.dto.manager.v2.deployment.request import (
1616
AdminSearchRevisionsInput,
1717
)
18+
from ai.backend.common.identifier.deployment_revision import DeploymentRevisionID
1819
from ai.backend.manager.api.gql.base import encode_cursor, resolve_global_id
1920
from ai.backend.manager.api.gql.decorators import (
2021
BackendAIGQLMeta,
@@ -97,7 +98,9 @@ async def revisions(
9798
async def revision(id: ID, info: Info[StrawberryGQLContext]) -> ModelRevision | None:
9899
"""Get a specific revision by ID."""
99100
_, revision_id = resolve_global_id(id)
100-
node = await info.context.adapters.deployment.get_revision(UUID(revision_id))
101+
node = await info.context.adapters.deployment.get_revision(
102+
DeploymentRevisionID(UUID(revision_id))
103+
)
101104
return ModelRevision.from_pydantic(node)
102105

103106

src/ai/backend/manager/api/gql/deployment/types/revision.py

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
PreStartActionInfoDTO,
102102
ResourceConfigInfoDTO,
103103
)
104+
from ai.backend.common.identifier.deployment_revision import DeploymentRevisionID
104105
from ai.backend.common.meta import NEXT_RELEASE_VERSION
105106
from ai.backend.common.types import MountPermission as CommonMountPermission
106107
from ai.backend.manager.api.gql.base import (
@@ -452,6 +453,16 @@ class ModelDefinitionGQL:
452453
class ModelRevision(PydanticNodeMixin[RevisionNodeDTO]):
453454
image_id: ID
454455
id: NodeID[str]
456+
revision_number: int = gql_added_field(
457+
BackendAIGQLMeta(
458+
added_version=NEXT_RELEASE_VERSION,
459+
description=(
460+
"Per-deployment sequential revision number assigned at "
461+
"insert time (UNIQUE per deployment). Use this to surface "
462+
"'Revision #N' labels and to order revisions client-side."
463+
),
464+
),
465+
)
455466
cluster_config: ClusterConfig = gql_field(
456467
description="Cluster configuration for replica distribution."
457468
)
@@ -558,7 +569,7 @@ async def resource_slots(
558569
)
559570

560571
payload = await info.context.adapters.deployment.search_revision_resource_slots(
561-
revision_id=UUID(str(self.id)),
572+
revision_id=DeploymentRevisionID(UUID(str(self.id))),
562573
input=SearchAllocatedResourceSlotsInput(
563574
filter=filter.to_pydantic() if filter else None,
564575
order=[o.to_pydantic() for o in order_by] if order_by else None,

src/ai/backend/manager/api/rest/deployment/handler.py

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
)
4545
from ai.backend.common.dto.manager.deployment.response import DeploymentDTO, RevisionDTO
4646
from ai.backend.common.identifier.deployment import DeploymentID
47+
from ai.backend.common.identifier.deployment_revision import DeploymentRevisionID
4748
from ai.backend.common.identifier.runtime_variant import RuntimeVariantID
4849
from ai.backend.common.types import RuntimeVariant
4950
from ai.backend.manager.api.adapters.runtime_variant.adapter import RuntimeVariantAdapter
@@ -223,7 +224,7 @@ async def get_deployment(
223224
"""Get a specific deployment."""
224225
# Call service action - raises EndpointNotFound if not found
225226
action_result = await self._deployment.get_deployment_by_id.wait_for_complete(
226-
GetDeploymentByIdAction(deployment_id=path.parsed.deployment_id)
227+
GetDeploymentByIdAction(deployment_id=DeploymentID(path.parsed.deployment_id))
227228
)
228229

229230
# Build response
@@ -302,7 +303,7 @@ async def add_revision(
302303
# Call service action
303304
action_result = await self._deployment.add_model_revision.wait_for_complete(
304305
AddModelRevisionAction(
305-
model_deployment_id=path.parsed.deployment_id,
306+
model_deployment_id=DeploymentID(path.parsed.deployment_id),
306307
adder=revision_creator,
307308
auto_activate=body.parsed.options.auto_activate,
308309
)
@@ -349,7 +350,7 @@ async def get_revision(
349350
"""Get a specific revision."""
350351
# Call service action - raises DeploymentRevisionNotFound if not found
351352
action_result = await self._deployment.get_revision_by_id.wait_for_complete(
352-
GetRevisionByIdAction(revision_id=path.parsed.revision_id)
353+
GetRevisionByIdAction(revision_id=DeploymentRevisionID(path.parsed.revision_id))
353354
)
354355

355356
# Build response
@@ -364,8 +365,8 @@ async def activate_revision(
364365
# Call service action
365366
await self._deployment.activate_revision.wait_for_complete(
366367
ActivateRevisionAction(
367-
deployment_id=path.parsed.deployment_id,
368-
revision_id=path.parsed.revision_id,
368+
deployment_id=DeploymentID(path.parsed.deployment_id),
369+
revision_id=DeploymentRevisionID(path.parsed.revision_id),
369370
)
370371
)
371372

src/ai/backend/manager/api/rest/service/handler.py

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@
6868
DeploymentNetworkSpec,
6969
ExecutionSpec,
7070
ImageIdentifierDraft,
71-
ModelRevisionSpec,
71+
ModelRevisionData,
7272
ModelRevisionSpecDraft,
7373
MountMetadata,
7474
ReplicaSpec,
@@ -168,12 +168,12 @@ def _serve_info_from_dto(dto: ServiceInfo, runtime_variant_name: RuntimeVariant)
168168
)
169169

170170

171-
def _resolve_target_revision_spec(info: DeploymentInfo) -> ModelRevisionSpec | None:
172-
"""Resolve the target revision spec by id (current first, then deploying)."""
171+
def _resolve_target_revision_data(info: DeploymentInfo) -> ModelRevisionData | None:
172+
"""Resolve the target revision data by id (current first, then deploying)."""
173173
target_id = info.current_revision_id or info.deploying_revision_id
174174
if target_id is None:
175175
return None
176-
return next((r for r in info.model_revisions if r.revision_id == target_id), None)
176+
return next((r for r in info.model_revisions if r.id == target_id), None)
177177

178178

179179
def _serve_info_from_deployment_info(
@@ -186,22 +186,22 @@ def _serve_info_from_deployment_info(
186186
active revision's ``runtime_variant_id`` (internal data types are
187187
id-only; the legacy REST response still exposes the name string).
188188
"""
189-
model_revision = _resolve_target_revision_spec(deployment_info)
189+
model_revision = _resolve_target_revision_data(deployment_info)
190190

191191
return ServeInfoModel(
192192
endpoint_id=deployment_info.id,
193193
# ``None`` here covers two cases: no revision exists, or the revision's
194194
# model vfolder has been deleted (SET NULL FK).
195-
model_id=model_revision.mounts.model_vfolder_id if model_revision else None,
196-
extra_mounts=[m.vfolder_id for m in model_revision.mounts.extra_mounts]
195+
model_id=model_revision.model_mount_config.vfolder_id if model_revision else None,
196+
extra_mounts=[m.vfolder_id for m in model_revision.model_mount_config.extra_mounts]
197197
if model_revision
198198
else [],
199199
name=deployment_info.metadata.name,
200-
model_definition_path=model_revision.mounts.model_definition_path
200+
model_definition_path=model_revision.model_mount_config.definition_path
201201
if model_revision
202202
else None,
203-
replicas=deployment_info.replica_spec.replica_count,
204-
desired_session_count=deployment_info.replica_spec.replica_count,
203+
replicas=deployment_info.replica.replica_count,
204+
desired_session_count=deployment_info.replica.replica_count,
205205
active_routes=[],
206206
service_endpoint=HttpUrl(deployment_info.network.url)
207207
if deployment_info.network.url
@@ -387,11 +387,11 @@ async def create(self, body: BodyParam[NewServiceRequestModel], req: RequestCtx)
387387
await self._deployment.create_legacy_deployment.wait_for_complete(deployment_action)
388388
)
389389
deployment_info = deployment_result.data
390-
model_revision = _resolve_target_revision_spec(deployment_info)
390+
model_revision = _resolve_target_revision_data(deployment_info)
391391
if model_revision is None:
392392
raise RuntimeVariantNotFound()
393393
runtime_variant_name = await self._resolve_runtime_variant_name(
394-
model_revision.execution.runtime_variant_id
394+
model_revision.model_runtime_config.runtime_variant_id
395395
)
396396
resp = _serve_info_from_deployment_info(deployment_info, runtime_variant_name)
397397
return APIResponse.build(HTTPStatus.CREATED, resp)

0 commit comments

Comments
 (0)