Skip to content

Commit 04729ce

Browse files
lilyz-aiclaude
andcommitted
feat: implement temporal endpoint type MVP (MLI-6425)
Adds TEMPORAL as a fourth endpoint type in Launch so teams can run Temporal activity worker pods through the standard Launch API instead of maintaining custom raw K8s Deployments. - domain/entities: add TEMPORAL to ModelEndpointType enum - domain/entities: add temporal_task_queue field to ModelEndpointRecord - common/dtos: add temporal_task_queue to Create/Update request DTOs with validator requiring it for temporal endpoints - domain/use_cases: allow min_workers=0 for temporal (like async) - domain/services + infra/services: thread temporal_task_queue through create/update endpoint call chain - db/models: add temporal_task_queue column to ORM model - db/migrations: alembic migration to add the column - infra/repositories: persist and read temporal_task_queue - k8s_resource_types: add DeploymentRunnableImageTemporalCpuArguments and DeploymentRunnableImageTemporalGpuArguments TypedDicts; add temporal branch in get_endpoint_resource_arguments_from_request - k8s_endpoint_resource_delegate: add temporal resource name handling in _create_or_update_resources, _get_resources_from_deployment_type, _get_temporal_autoscaling_params (reads annotations); skip SQS for temporal in delete_resources - live_endpoint_resource_gateway: skip SQS queue deletion for temporal - service_template_config_map_circleci.yaml: add cpu+gpu temporal deployment templates (fixed replicas=MAX_WORKERS, no forwarder sidecar, temporal annotations) - service_template_config_map.yaml (Helm): same templates for prod MVP uses fixed replicas = max_workers; KEDA autoscaling is phase 2. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6b14a3d commit 04729ce

14 files changed

Lines changed: 629 additions & 9 deletions

charts/model-engine/templates/service_template_config_map.yaml

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,146 @@ data:
408408
{{- end }}
409409
{{- end }}
410410
{{- end }}
411+
{{- range $device := tuple "cpu" "gpu" }}
412+
deployment-runnable-image-temporal-{{ $device }}.yaml: |-
413+
apiVersion: apps/v1
414+
kind: Deployment
415+
metadata:
416+
name: ${RESOURCE_NAME}
417+
namespace: ${NAMESPACE}
418+
labels:
419+
{{- $service_template_labels | nindent 8 }}
420+
annotations:
421+
temporal.scaleml.io/taskQueue: "${TEMPORAL_TASK_QUEUE}"
422+
temporal.scaleml.io/minWorkers: "${MIN_WORKERS}"
423+
temporal.scaleml.io/maxWorkers: "${MAX_WORKERS}"
424+
temporal.scaleml.io/perWorker: "${PER_WORKER}"
425+
spec:
426+
strategy:
427+
type: RollingUpdate
428+
rollingUpdate:
429+
maxSurge: 1
430+
maxUnavailable: 0
431+
replicas: ${MAX_WORKERS}
432+
selector:
433+
matchLabels:
434+
app: ${RESOURCE_NAME}
435+
version: v1
436+
template:
437+
metadata:
438+
labels:
439+
app: ${RESOURCE_NAME}
440+
{{- $service_template_labels | nindent 12 }}
441+
sidecar.istio.io/inject: "false"
442+
version: v1
443+
annotations:
444+
ad.datadoghq.com/main.logs: '[{"service": "${ENDPOINT_NAME}", "source": "python"}]'
445+
kubernetes.io/change-cause: "${CHANGE_CAUSE_MESSAGE}"
446+
spec:
447+
affinity:
448+
{{- include "modelEngine.serviceTemplateAffinity" . | nindent 12 }}
449+
terminationGracePeriodSeconds: 1800
450+
{{- if $service_template_service_account_name }}
451+
serviceAccount: {{ $service_template_service_account_name }}
452+
{{- else }}
453+
serviceAccount: {{ $launch_name }}
454+
{{- end }}
455+
{{- with $node_selector }}
456+
nodeSelector:
457+
{{- toYaml . | nindent 12 }}
458+
{{- end }}
459+
{{- if eq $device "gpu" }}
460+
{{- if empty $node_selector }}
461+
nodeSelector:
462+
{{- end }}
463+
k8s.amazonaws.com/accelerator: ${GPU_TYPE}
464+
tolerations:
465+
- key: "nvidia.com/gpu"
466+
operator: "Exists"
467+
effect: "NoSchedule"
468+
{{- range $gpu_tolerations }}
469+
- key: {{ .key | quote }}
470+
operator: "Exists"
471+
effect: "NoSchedule"
472+
{{- end }}
473+
{{- end }}
474+
priorityClassName: ${PRIORITY}
475+
containers:
476+
- name: main
477+
{{- $security_context | nindent 14 }}
478+
image: ${IMAGE}
479+
imagePullPolicy: IfNotPresent
480+
command: ${COMMAND}
481+
env: ${MAIN_ENV}
482+
resources:
483+
requests:
484+
{{- if eq $device "gpu" }}
485+
nvidia.com/gpu: ${GPUS}
486+
{{- end }}
487+
cpu: ${CPUS}
488+
memory: ${MEMORY}
489+
${STORAGE_DICT}
490+
limits:
491+
{{- if eq $device "gpu" }}
492+
nvidia.com/gpu: ${GPUS}
493+
{{- end }}
494+
cpu: ${CPUS}
495+
memory: ${MEMORY}
496+
${STORAGE_DICT}
497+
volumeMounts:
498+
{{- if $require_aws_config }}
499+
- name: aws-config-volume
500+
mountPath: /opt/.aws/config
501+
subPath: config
502+
{{- end }}
503+
{{- if eq $device "gpu" }}
504+
- mountPath: /dev/shm
505+
name: dshm
506+
{{- end }}
507+
- name: user-config
508+
mountPath: /app/user_config
509+
subPath: raw_data
510+
- name: endpoint-config
511+
mountPath: /app/endpoint_config
512+
subPath: raw_data
513+
{{- if $config_values }}
514+
- name: infra-service-config-volume
515+
mountPath: ${INFRA_SERVICE_CONFIG_VOLUME_MOUNT_PATH}
516+
{{- end }}
517+
{{- if $security_context }}
518+
securityContext:
519+
fsGroup: 65534
520+
{{- end }}
521+
volumes:
522+
{{- if $require_aws_config }}
523+
- name: aws-config-volume
524+
configMap:
525+
{{- if $service_template_aws_config_map_name }}
526+
name: {{ $service_template_aws_config_map_name }}
527+
{{- else }}
528+
name: {{ $aws_config_map_name }}
529+
{{- end }}
530+
{{- end }}
531+
- name: user-config
532+
configMap:
533+
name: ${RESOURCE_NAME}
534+
- name: endpoint-config
535+
configMap:
536+
name: ${RESOURCE_NAME}-endpoint-config
537+
{{- if eq $device "gpu" }}
538+
- name: dshm
539+
emptyDir:
540+
medium: Memory
541+
{{- end }}
542+
{{- if $config_values }}
543+
- name: infra-service-config-volume
544+
configMap:
545+
name: {{ $launch_name }}-service-config
546+
items:
547+
- key: infra_service_config
548+
path: config.yaml
549+
{{- end }}
550+
{{- end }}
411551
user-config.yaml: |-
412552
apiVersion: v1
413553
kind: ConfigMap

model-engine/model_engine_server/common/dtos/model_endpoints.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
from typing import Any, Dict, List, Optional
1212

1313
from model_engine_server.common.dtos.core import HttpUrlStr
14-
from model_engine_server.common.pydantic_types import BaseModel, ConfigDict, Field
14+
from model_engine_server.common.pydantic_types import BaseModel, ConfigDict, Field, model_validator
1515
from model_engine_server.domain.entities import (
1616
CallbackAuth,
1717
CpuSpecificationType,
@@ -84,6 +84,16 @@ class CreateModelEndpointV1Request(BaseModel):
8484
ge=1,
8585
description="For async endpoints, how long a task can wait in queue before expiring (in seconds). Default: 86400 (24 hours).",
8686
)
87+
temporal_task_queue: Optional[str] = Field(
88+
default=None,
89+
description="For temporal endpoints, the Temporal task queue that workers will poll.",
90+
)
91+
92+
@model_validator(mode="after")
93+
def validate_temporal_task_queue(self) -> "CreateModelEndpointV1Request":
94+
if self.endpoint_type == ModelEndpointType.TEMPORAL and not self.temporal_task_queue:
95+
raise ValueError("temporal_task_queue is required for temporal endpoints")
96+
return self
8797

8898

8999
class CreateModelEndpointV1Response(BaseModel):
@@ -122,6 +132,10 @@ class UpdateModelEndpointV1Request(BaseModel):
122132
ge=1,
123133
description="For async endpoints, how long a task can wait in queue before expiring (in seconds). Default: 86400 (24 hours).",
124134
)
135+
temporal_task_queue: Optional[str] = Field(
136+
default=None,
137+
description="For temporal endpoints, the Temporal task queue that workers will poll.",
138+
)
125139

126140

127141
class UpdateModelEndpointV1Response(BaseModel):
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""add temporal_task_queue column
2+
3+
Revision ID: b2c3d4e5f6g7
4+
Revises: a1b2c3d4e5f6
5+
Create Date: 2026-04-24 00:00:00.000000
6+
7+
"""
8+
import sqlalchemy as sa
9+
from alembic import op
10+
11+
# revision identifiers, used by Alembic.
12+
revision = 'b2c3d4e5f6g7'
13+
down_revision = 'a1b2c3d4e5f6'
14+
branch_labels = None
15+
depends_on = None
16+
17+
18+
def upgrade() -> None:
19+
op.add_column(
20+
'endpoints',
21+
sa.Column('temporal_task_queue', sa.String, nullable=True),
22+
schema='hosted_model_inference',
23+
)
24+
25+
26+
def downgrade() -> None:
27+
op.drop_column(
28+
'endpoints',
29+
'temporal_task_queue',
30+
schema='hosted_model_inference',
31+
)

model-engine/model_engine_server/db/models/hosted_model_inference.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -472,6 +472,8 @@ class Endpoint(Base):
472472
task_expires_seconds = Column(Integer, nullable=True)
473473
# Queue message visibility/lock timeout in seconds (SQS VisibilityTimeout / ASB lock_duration)
474474
queue_message_timeout_seconds = Column(Integer, nullable=True)
475+
# Temporal task queue name for temporal endpoints
476+
temporal_task_queue = Column(String, nullable=True)
475477

476478
def __init__(
477479
self,
@@ -488,6 +490,7 @@ def __init__(
488490
public_inference: Optional[bool] = False,
489491
task_expires_seconds: Optional[int] = None,
490492
queue_message_timeout_seconds: Optional[int] = None,
493+
temporal_task_queue: Optional[str] = None,
491494
):
492495
self.id = f"end_{get_xid()}"
493496
self.name = name
@@ -502,6 +505,7 @@ def __init__(
502505
self.public_inference = public_inference
503506
self.task_expires_seconds = task_expires_seconds
504507
self.queue_message_timeout_seconds = queue_message_timeout_seconds
508+
self.temporal_task_queue = temporal_task_queue
505509

506510
@classmethod
507511
async def create(cls, session: AsyncSession, endpoint: "Endpoint") -> None:

model-engine/model_engine_server/domain/entities/model_endpoint_entity.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ class ModelEndpointType(str, Enum):
2222
ASYNC = "async"
2323
SYNC = "sync"
2424
STREAMING = "streaming"
25+
TEMPORAL = "temporal"
2526

2627

2728
class ModelEndpointStatus(str, Enum):
@@ -136,6 +137,7 @@ class ModelEndpointRecord(OwnedEntity):
136137
public_inference: Optional[bool] = None
137138
task_expires_seconds: Optional[int] = None
138139
queue_message_timeout_seconds: Optional[int] = None
140+
temporal_task_queue: Optional[str] = None
139141

140142

141143
class ModelEndpointInfraState(BaseModel):

model-engine/model_engine_server/domain/services/model_endpoint_service.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ async def create_model_endpoint(
9494
public_inference: Optional[bool] = False,
9595
queue_message_timeout_seconds: Optional[int] = None,
9696
task_expires_seconds: Optional[int] = None,
97+
temporal_task_queue: Optional[str] = None,
9798
) -> ModelEndpointRecord:
9899
"""
99100
Creates a model endpoint.
@@ -230,6 +231,7 @@ async def update_model_endpoint(
230231
public_inference: Optional[bool] = None,
231232
queue_message_timeout_seconds: Optional[int] = None,
232233
task_expires_seconds: Optional[int] = None,
234+
temporal_task_queue: Optional[str] = None,
233235
) -> ModelEndpointRecord:
234236
"""
235237
Updates a model endpoint.

model-engine/model_engine_server/domain/use_cases/model_endpoint_use_cases.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,10 @@ def validate_deployment_resources(
116116
# TODO: we should be also validating the update request against the existing state in k8s (e.g.
117117
# so min_workers <= max_workers always) maybe this occurs already in update_model_endpoint.
118118
min_endpoint_size = (
119-
0 if endpoint_type == ModelEndpointType.ASYNC or can_scale_http_endpoint_from_zero else 1
119+
0
120+
if endpoint_type in {ModelEndpointType.ASYNC, ModelEndpointType.TEMPORAL}
121+
or can_scale_http_endpoint_from_zero
122+
else 1
120123
)
121124
if min_workers is not None and min_workers < min_endpoint_size:
122125
raise EndpointResourceInvalidRequestException(
@@ -393,6 +396,7 @@ async def execute(
393396
public_inference=request.public_inference,
394397
queue_message_timeout_seconds=request.queue_message_timeout_seconds,
395398
task_expires_seconds=request.task_expires_seconds,
399+
temporal_task_queue=request.temporal_task_queue,
396400
)
397401
_handle_post_inference_hooks(
398402
created_by=user.user_id,
@@ -523,6 +527,7 @@ async def execute(
523527
public_inference=request.public_inference,
524528
queue_message_timeout_seconds=request.queue_message_timeout_seconds,
525529
task_expires_seconds=request.task_expires_seconds,
530+
temporal_task_queue=request.temporal_task_queue,
526531
)
527532
_handle_post_inference_hooks(
528533
created_by=endpoint_record.created_by,

model-engine/model_engine_server/infra/gateways/resources/k8s_endpoint_resource_delegate.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -420,7 +420,11 @@ async def delete_resources(
420420
self, endpoint_id: str, deployment_name: str, endpoint_type: ModelEndpointType
421421
) -> bool:
422422
await maybe_load_kube_config()
423-
if endpoint_type in {ModelEndpointType.SYNC, ModelEndpointType.STREAMING}:
423+
if endpoint_type in {
424+
ModelEndpointType.SYNC,
425+
ModelEndpointType.STREAMING,
426+
ModelEndpointType.TEMPORAL,
427+
}:
424428
return await self._delete_resources_sync(
425429
endpoint_id=endpoint_id, deployment_name=deployment_name
426430
)
@@ -1872,6 +1876,7 @@ async def _create_or_update_resources(
18721876
if model_endpoint_record.endpoint_type in {
18731877
ModelEndpointType.SYNC,
18741878
ModelEndpointType.STREAMING,
1879+
ModelEndpointType.TEMPORAL,
18751880
}:
18761881
return k8s_service_name
18771882
elif model_endpoint_record.endpoint_type == ModelEndpointType.ASYNC:
@@ -1898,6 +1903,18 @@ def _get_vertical_autoscaling_params(
18981903
max_memory=str(policy["maxAllowed"]["memory"]),
18991904
)
19001905

1906+
@staticmethod
1907+
def _get_temporal_autoscaling_params(
1908+
deployment_config,
1909+
) -> HorizontalAutoscalingEndpointParams:
1910+
metadata_annotations = deployment_config.metadata.annotations or {}
1911+
return dict(
1912+
min_workers=int(metadata_annotations.get("temporal.scaleml.io/minWorkers", 0)),
1913+
max_workers=int(metadata_annotations.get("temporal.scaleml.io/maxWorkers", 0)),
1914+
per_worker=int(metadata_annotations.get("temporal.scaleml.io/perWorker", 1)),
1915+
concurrent_requests_per_worker=1,
1916+
)
1917+
19011918
@staticmethod
19021919
def _get_async_autoscaling_params(
19031920
deployment_config,
@@ -2013,6 +2030,8 @@ async def _get_resources_from_deployment_type(
20132030
common_params = self._get_common_endpoint_params(deployment_config)
20142031
if endpoint_type == ModelEndpointType.ASYNC:
20152032
horizontal_autoscaling_params = self._get_async_autoscaling_params(deployment_config)
2033+
elif endpoint_type == ModelEndpointType.TEMPORAL:
2034+
horizontal_autoscaling_params = self._get_temporal_autoscaling_params(deployment_config)
20162035
elif endpoint_type in {ModelEndpointType.SYNC, ModelEndpointType.STREAMING}:
20172036
autoscaling_client = get_kubernetes_autoscaling_client()
20182037
custom_object_client = get_kubernetes_custom_objects_client()

0 commit comments

Comments
 (0)