Skip to content

Commit 8db0917

Browse files
committed
Merge remote-tracking branch 'origin/master' into issue_4126_gateway_default_in_place_update
2 parents 0954e90 + 0d25f21 commit 8db0917

20 files changed

Lines changed: 682 additions & 215 deletions

File tree

‎src/dstack/_internal/server/app.py‎

Lines changed: 0 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@
5555
from dstack._internal.server.services.jobs.server_connection import job_server_connections_pool
5656
from dstack._internal.server.services.locking import advisory_lock_ctx
5757
from dstack._internal.server.services.projects import get_or_create_default_project
58-
from dstack._internal.server.services.prometheus.client_metrics import http_metrics
5958
from dstack._internal.server.services.proxy.deps import ServerProxyDependencyInjector
6059
from dstack._internal.server.services.proxy.routers import service_proxy
6160
from dstack._internal.server.services.runner.pool import instance_connection_pool
@@ -299,8 +298,6 @@ async def log_request(request: Request, call_next):
299298
start_time = time.time()
300299
response: Response = await call_next(request)
301300
process_time = time.time() - start_time
302-
# log process_time to be used in the log_http_metrics middleware
303-
request.state.process_time = process_time
304301
logger.debug(
305302
"Processed request %s %s in %s. Status: %s",
306303
request.method,
@@ -327,42 +324,6 @@ async def profile_request(request: Request, call_next):
327324
else:
328325
return await call_next(request)
329326

330-
# this middleware must be defined after the log_request middleware
331-
@app.middleware("http")
332-
async def log_http_metrics(request: Request, call_next):
333-
def _extract_project_name(request: Request):
334-
project_name = None
335-
prefix = "/api/project/"
336-
if request.url.path.startswith(prefix):
337-
rest = request.url.path[len(prefix) :]
338-
project_name = rest.split("/", 1)[0] if rest else None
339-
340-
return project_name
341-
342-
def _extract_endpoint_label(request: Request, response: Response) -> str:
343-
route = request.scope.get("route")
344-
route_path = getattr(route, "path", None)
345-
if route_path:
346-
return route_path
347-
if not request.url.path.startswith("/api/"):
348-
return "__non_api__"
349-
if response.status_code == status.HTTP_404_NOT_FOUND:
350-
return "__not_found__"
351-
return "__unmatched__"
352-
353-
project_name = _extract_project_name(request)
354-
response: Response = await call_next(request)
355-
endpoint_label = _extract_endpoint_label(request, response)
356-
357-
http_metrics.log_request(
358-
method=request.method,
359-
endpoint=endpoint_label,
360-
http_status=response.status_code,
361-
project_name=project_name,
362-
duration_seconds=request.state.process_time,
363-
)
364-
return response
365-
366327
@app.get("/healthcheck")
367328
async def healthcheck():
368329
return CustomJSONResponse(content={"status": "running"})

‎src/dstack/_internal/server/background/pipeline_tasks/fleets.py‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
InstanceModel,
3939
JobModel,
4040
PlacementGroupModel,
41+
ProjectModel,
4142
RunModel,
4243
)
4344
from dstack._internal.server.services import events
@@ -338,7 +339,7 @@ async def _refetch_locked_fleet_for_processing(
338339
FleetModel.id == item.id,
339340
FleetModel.lock_token == item.lock_token,
340341
)
341-
.options(joinedload(FleetModel.project))
342+
.options(joinedload(FleetModel.project).joinedload(ProjectModel.owner))
342343
.options(
343344
selectinload(FleetModel.instances.and_(InstanceModel.deleted == False))
344345
.joinedload(InstanceModel.jobs)

‎src/dstack/_internal/server/background/pipeline_tasks/jobs_running.py‎

Lines changed: 46 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
)
2121
from dstack._internal.core.models.configurations import (
2222
DevEnvironmentConfiguration,
23-
ServiceConfiguration,
2423
)
2524
from dstack._internal.core.models.files import FileArchiveMapping
2625
from dstack._internal.core.models.instances import InstanceStatus, SSHConnectionParams
@@ -34,7 +33,6 @@
3433
Job,
3534
JobProvisioningData,
3635
JobRuntimeData,
37-
JobSpec,
3836
JobStatus,
3937
JobSubmission,
4038
JobTerminationReason,
@@ -366,6 +364,7 @@ class _JobUpdateMap(ItemUpdateMap, total=False):
366364
disconnected_at: Optional[datetime]
367365
inactivity_secs: Optional[int]
368366
exit_status: Optional[int]
367+
ready: bool
369368
registered: bool
370369
image_pull_progress: Optional[str]
371370
skip_min_processing_interval: bool
@@ -1083,6 +1082,12 @@ def _emit_result_events(
10831082
job_model.disconnected_at,
10841083
),
10851084
)
1085+
_emit_readiness_change_event(
1086+
session=session,
1087+
job_model=job_model,
1088+
old_ready=job_model.ready,
1089+
new_ready=result.job_update_map.get("ready", job_model.ready),
1090+
)
10861091
if result.replica_registration is not None:
10871092
targets = [events.Target.from_model(job_model)]
10881093
if result.replica_registration.gateway_target is not None:
@@ -1175,13 +1180,32 @@ async def _maybe_register_replica(
11751180
) -> None:
11761181
if (
11771182
context.run.run_spec.configuration.type != "service"
1178-
or _get_result_registered(context.job_model, result)
11791183
or context.job_model.job_num != 0
11801184
or result.new_probe_models
1181-
or not is_job_ready(context.job_model.probes, context.job.job_spec.probes)
11821185
):
11831186
return
11841187

1188+
is_ready = is_job_ready(context.job_model.probes, context.job.job_spec.probes)
1189+
if is_ready and not context.job_model.ready:
1190+
result.job_update_map["ready"] = True
1191+
1192+
router_group = next(
1193+
(g for g in context.run.run_spec.configuration.replica_groups if g.router is not None),
1194+
None,
1195+
)
1196+
is_router_replica = (
1197+
router_group is not None and context.job.job_spec.replica_group == router_group.name
1198+
)
1199+
# non-router replicas aren't registered if the service has a router
1200+
if router_group is not None and not is_router_replica:
1201+
if context.job_model.registered:
1202+
# migration edge case: a pre-0.21.1 server replica incorrectly set registered=True
1203+
result.job_update_map["registered"] = False
1204+
return
1205+
1206+
if not is_ready or _get_result_registered(context.job_model, result):
1207+
return
1208+
11851209
ssh_head_proxy: Optional[SSHConnectionParams] = None
11861210
ssh_head_proxy_private_key: Optional[str] = None
11871211
instance = get_or_error(context.job_model.instance)
@@ -1220,23 +1244,6 @@ async def _register_service_replica(
12201244
) -> Optional[events.Target]:
12211245
if context.run_model.gateway_id is None:
12221246
return None
1223-
1224-
job_spec = validate_json_extra_ignore(JobSpec, context.job_model.job_spec_data)
1225-
1226-
# For router-based services (e.g. PD disaggregation), only router replicas should be
1227-
# registered with the gateway. Worker replicas are discovered by the router-worker
1228-
# sync pipeline and should not be routed to directly by the gateway.
1229-
config = context.run.run_spec.configuration
1230-
assert isinstance(config, ServiceConfiguration)
1231-
router_group = next((g for g in config.replica_groups if g.router is not None), None)
1232-
is_router_replica = router_group is not None and job_spec.replica_group == router_group.name
1233-
if router_group is not None and not is_router_replica:
1234-
logger.debug(
1235-
"%s: skipping gateway replica registration (non-router replica)",
1236-
fmt(context.job_model),
1237-
)
1238-
return None
1239-
12401247
async with get_session_ctx() as session:
12411248
gateway_model, connections = await get_or_add_gateway_connections(
12421249
session, context.run_model.gateway_id
@@ -1261,7 +1268,7 @@ async def _register_service_replica(
12611268
async with conn.client() as gateway_client:
12621269
await gateway_client.register_replica(
12631270
run=context.run,
1264-
job_spec=job_spec,
1271+
job_spec=context.job.job_spec,
12651272
job_submission=job_submission,
12661273
instance_project_ssh_private_key=instance_project_ssh_private_key,
12671274
ssh_head_proxy=ssh_head_proxy,
@@ -1877,6 +1884,23 @@ def _emit_reachability_change_event(
18771884
)
18781885

18791886

1887+
def _emit_readiness_change_event(
1888+
session: AsyncSession,
1889+
job_model: JobModel,
1890+
old_ready: bool,
1891+
new_ready: bool,
1892+
) -> None:
1893+
# ready: False -> True
1894+
if not old_ready and new_ready:
1895+
events.emit(
1896+
session,
1897+
"Service replica ready to receive requests",
1898+
actor=events.SystemActor(),
1899+
targets=[events.Target.from_model(job_model)],
1900+
)
1901+
# ready: True -> False is not possible as of this writing
1902+
1903+
18801904
def _terminate_job(
18811905
job_model: JobModel,
18821906
job_update_map: _JobUpdateMap,

‎src/dstack/_internal/server/background/pipeline_tasks/runs/active.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -686,11 +686,11 @@ async def _build_rolling_deployment_maps(
686686
max_new = max(j.replica_num for j in new_jobs)
687687
next_replica_num = max(next_replica_num, max_new + 1)
688688

689-
# Scale down: terminate unregistered out-of-date + excess registered replicas
690-
replicas_to_stop = state.unregistered_out_of_date_replica_count
689+
# Scale down: terminate unready out-of-date + excess ready replicas
690+
replicas_to_stop = state.unready_out_of_date_replica_count
691691
replicas_to_stop += max(
692692
0,
693-
state.registered_non_terminating_replica_count - group_desired,
693+
state.ready_non_terminating_replica_count - group_desired,
694694
)
695695
if replicas_to_stop > 0:
696696
scale_down_maps = _build_scale_down_job_update_maps(

‎src/dstack/_internal/server/background/pipeline_tasks/service_router_worker_sync.py‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -233,13 +233,12 @@ async def process(self, item: ServiceRouterWorkerSyncPipelineItem) -> None:
233233
selectinload(
234234
RunModel.jobs.and_(
235235
JobModel.status == JobStatus.RUNNING,
236-
JobModel.registered == True,
236+
JobModel.ready == True,
237237
)
238238
)
239239
.load_only(
240240
JobModel.id,
241241
JobModel.status,
242-
JobModel.registered,
243242
JobModel.job_spec_data,
244243
JobModel.job_provisioning_data,
245244
JobModel.job_runtime_data,
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"""Add JobModel.ready
2+
3+
Revision ID: 72cfa56364ad
4+
Revises: a1c3f5e7b209
5+
Create Date: 2026-08-04 07:02:02.041000+00:00
6+
7+
"""
8+
9+
import json
10+
import uuid
11+
from typing import Optional
12+
13+
import sqlalchemy as sa
14+
from alembic import op
15+
from sqlalchemy_utils import UUIDType
16+
17+
# revision identifiers, used by Alembic.
18+
revision = "72cfa56364ad"
19+
down_revision = "a1c3f5e7b209"
20+
branch_labels = None
21+
depends_on = None
22+
23+
# Partial table descriptions - only columns needed for the data migration below.
24+
jobs_table = sa.Table(
25+
"jobs",
26+
sa.MetaData(),
27+
sa.Column("id", UUIDType(binary=False), primary_key=True, default=uuid.uuid4),
28+
sa.Column("run_id", UUIDType(binary=False)),
29+
sa.Column("status", sa.String(100)),
30+
sa.Column("registered", sa.Boolean()),
31+
sa.Column("ready", sa.Boolean()),
32+
sa.Column("job_spec_data", sa.Text()),
33+
)
34+
runs_table = sa.Table(
35+
"runs",
36+
sa.MetaData(),
37+
sa.Column("id", UUIDType(binary=False), primary_key=True, default=uuid.uuid4),
38+
sa.Column("run_spec", sa.Text()),
39+
sa.Column("service_spec", sa.Text(), nullable=True),
40+
)
41+
42+
43+
def _get_router_group_name(run_spec_data: str) -> Optional[str]:
44+
configuration = json.loads(run_spec_data).get("configuration") or {}
45+
if configuration.get("type") != "service":
46+
return None
47+
replica_groups = configuration.get("replicas")
48+
if not isinstance(replica_groups, list):
49+
return None
50+
for group in replica_groups:
51+
if isinstance(group, dict) and group.get("router") is not None:
52+
return group.get("name")
53+
return None
54+
55+
56+
def _get_job_replica_group(job_spec_data: str) -> str:
57+
return json.loads(job_spec_data).get("replica_group", "0")
58+
59+
60+
def upgrade() -> None:
61+
# ### commands auto generated by Alembic - please adjust! ###
62+
with op.batch_alter_table("jobs", schema=None) as batch_op:
63+
batch_op.add_column(
64+
sa.Column("ready", sa.Boolean(), server_default=sa.false(), nullable=False)
65+
)
66+
67+
# ### end Alembic commands ###
68+
69+
bind = op.get_bind()
70+
71+
# backfill ready=True for existing registered replicas
72+
73+
bind.execute(jobs_table.update().where(jobs_table.c.registered == True).values(ready=True))
74+
75+
# set registered=False for non-router replicas in services with a router
76+
77+
candidate_jobs = bind.execute(
78+
sa.select(jobs_table.c.id, jobs_table.c.run_id, jobs_table.c.job_spec_data)
79+
.select_from(jobs_table.join(runs_table, jobs_table.c.run_id == runs_table.c.id))
80+
.where(
81+
jobs_table.c.registered == True,
82+
jobs_table.c.status == "RUNNING",
83+
runs_table.c.service_spec.is_not(None),
84+
sa.or_(
85+
runs_table.c.run_spec.like('%"sglang"%'),
86+
runs_table.c.run_spec.like('%"dynamo"%'),
87+
),
88+
)
89+
).all()
90+
91+
run_ids = {row.run_id for row in candidate_jobs}
92+
router_group_name_by_run_id = {}
93+
if run_ids:
94+
for row in bind.execute(
95+
sa.select(runs_table.c.id, runs_table.c.run_spec).where(runs_table.c.id.in_(run_ids))
96+
).all():
97+
router_group_name_by_run_id[row.id] = _get_router_group_name(row.run_spec)
98+
99+
non_router_job_ids = []
100+
for row in candidate_jobs:
101+
router_group_name = router_group_name_by_run_id.get(row.run_id)
102+
if router_group_name is None:
103+
continue
104+
if _get_job_replica_group(row.job_spec_data) != router_group_name:
105+
non_router_job_ids.append(row.id)
106+
107+
if non_router_job_ids:
108+
bind.execute(
109+
jobs_table.update()
110+
.where(jobs_table.c.id.in_(non_router_job_ids))
111+
.values(registered=False)
112+
)
113+
114+
115+
def downgrade() -> None:
116+
# ### commands auto generated by Alembic - please adjust! ###
117+
with op.batch_alter_table("jobs", schema=None) as batch_op:
118+
batch_op.drop_column("ready")
119+
120+
# ### end Alembic commands ###

0 commit comments

Comments
 (0)