Skip to content

Commit 2bb1a37

Browse files
author
Andrey Cheptsov
committed
Optimize runs list job loading
1 parent 5032fa6 commit 2bb1a37

3 files changed

Lines changed: 526 additions & 16 deletions

File tree

src/dstack/_internal/server/services/runs/__init__.py

Lines changed: 207 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import itertools
22
import math
33
import uuid
4+
from collections import defaultdict
45
from collections.abc import Iterable
56
from datetime import datetime, timezone
67
from typing import List, Optional
@@ -9,7 +10,8 @@
910
from apscheduler.triggers.cron import CronTrigger
1011
from sqlalchemy import and_, func, or_, select, update
1112
from sqlalchemy.ext.asyncio import AsyncSession
12-
from sqlalchemy.orm import joinedload, selectinload
13+
from sqlalchemy.orm import aliased, joinedload, noload, selectinload
14+
from sqlalchemy.orm.attributes import set_committed_value
1315

1416
import dstack._internal.utils.common as common_utils
1517
from dstack._internal.core.errors import (
@@ -37,11 +39,13 @@
3739
RunTerminationReason,
3840
ServiceSpec,
3941
)
42+
from dstack._internal.core.models.users import GlobalRole
4043
from dstack._internal.core.services.diff import format_diff_fields_for_event
4144
from dstack._internal.server.db import get_db, is_db_postgres, is_db_sqlite
4245
from dstack._internal.server.models import (
4346
FleetModel,
4447
JobModel,
48+
MemberModel,
4549
ProbeModel,
4650
ProjectModel,
4751
RepoModel,
@@ -63,7 +67,6 @@
6367
from dstack._internal.server.services.pipelines import PipelineHinterProtocol
6468
from dstack._internal.server.services.plugins import apply_plugin_policies
6569
from dstack._internal.server.services.probes import is_probe_ready
66-
from dstack._internal.server.services.projects import list_user_project_models
6770
from dstack._internal.server.services.resources import (
6871
set_gpu_vendor_default,
6972
set_resources_defaults,
@@ -168,32 +171,33 @@ async def list_user_runs(
168171
) -> List[Run]:
169172
if project_name is None and repo_id is not None:
170173
return []
171-
projects = await list_user_project_models(
172-
session=session,
173-
user=user,
174-
only_names=True,
175-
)
176174
runs_user = None
177175
if username is not None:
178176
runs_user = await get_user_model_by_name(session=session, username=username)
179177
if runs_user is None:
180178
raise ResourceNotExistsError("User not found")
181179
repo = None
180+
project = None
182181
if project_name is not None:
183-
projects = [p for p in projects if p.name == project_name]
184-
if len(projects) == 0:
182+
project = await _get_project_model_for_runs_list(
183+
session=session,
184+
user=user,
185+
project_name=project_name,
186+
)
187+
if project is None:
185188
return []
186189
if repo_id is not None:
187190
repo = await repos_services.get_repo_model(
188191
session=session,
189-
project=projects[0],
192+
project=project,
190193
repo_id=repo_id,
191194
)
192195
if repo is None:
193196
raise RepoDoesNotExistError.with_id(repo_id)
194197
run_models = await list_projects_run_models(
195198
session=session,
196-
projects=projects,
199+
user=user,
200+
project=project,
197201
repo=repo,
198202
runs_user=runs_user,
199203
only_active=only_active,
@@ -202,6 +206,13 @@ async def list_user_runs(
202206
limit=limit,
203207
ascending=ascending,
204208
)
209+
await _load_run_jobs_for_list(
210+
session=session,
211+
run_models=run_models,
212+
include_jobs=include_jobs,
213+
job_submissions_limit=job_submissions_limit,
214+
return_in_api=True,
215+
)
205216
runs = []
206217
for r in run_models:
207218
try:
@@ -220,9 +231,30 @@ async def list_user_runs(
220231
return runs
221232

222233

234+
async def _get_project_model_for_runs_list(
235+
session: AsyncSession,
236+
user: UserModel,
237+
project_name: str,
238+
) -> Optional[ProjectModel]:
239+
filters = [
240+
ProjectModel.name == project_name,
241+
ProjectModel.deleted == False,
242+
]
243+
if user.global_role != GlobalRole.ADMIN:
244+
filters.extend(
245+
[
246+
MemberModel.project_id == ProjectModel.id,
247+
MemberModel.user_id == user.id,
248+
]
249+
)
250+
res = await session.execute(select(ProjectModel).where(*filters))
251+
return res.scalar()
252+
253+
223254
async def list_projects_run_models(
224255
session: AsyncSession,
225-
projects: List[ProjectModel],
256+
user: UserModel,
257+
project: Optional[ProjectModel],
226258
repo: Optional[RepoModel],
227259
runs_user: Optional[UserModel],
228260
only_active: bool,
@@ -232,7 +264,24 @@ async def list_projects_run_models(
232264
ascending: bool,
233265
) -> List[RunModel]:
234266
filters = []
235-
filters.append(RunModel.project_id.in_(p.id for p in projects))
267+
if project is not None:
268+
filters.append(RunModel.project_id == project.id)
269+
elif user.global_role == GlobalRole.ADMIN:
270+
filters.append(
271+
RunModel.project_id.in_(select(ProjectModel.id).where(ProjectModel.deleted == False))
272+
)
273+
else:
274+
filters.append(
275+
RunModel.project_id.in_(
276+
select(MemberModel.project_id)
277+
.where(MemberModel.user_id == user.id)
278+
.where(
279+
MemberModel.project_id.in_(
280+
select(ProjectModel.id).where(ProjectModel.deleted == False)
281+
)
282+
)
283+
)
284+
)
236285
if repo is not None:
237286
filters.append(RunModel.repo_id == repo.id)
238287
if runs_user is not None:
@@ -271,16 +320,160 @@ async def list_projects_run_models(
271320
res = await session.execute(
272321
select(RunModel)
273322
.where(*filters)
323+
.options(joinedload(RunModel.project).load_only(ProjectModel.id, ProjectModel.name))
274324
.options(joinedload(RunModel.user).load_only(UserModel.name))
275325
.options(joinedload(RunModel.fleet).load_only(FleetModel.id, FleetModel.name))
276-
.options(selectinload(RunModel.jobs).joinedload(JobModel.probes))
326+
.options(noload(RunModel.jobs))
277327
.order_by(*order_by)
278328
.limit(limit)
279329
)
280330
run_models = list(res.scalars().all())
281331
return run_models
282332

283333

334+
async def _load_run_jobs_for_list(
335+
session: AsyncSession,
336+
run_models: List[RunModel],
337+
include_jobs: bool,
338+
job_submissions_limit: Optional[int],
339+
return_in_api: bool,
340+
) -> None:
341+
if len(run_models) == 0:
342+
return
343+
344+
effective_job_submissions_limit = job_submissions_limit if include_jobs else 0
345+
jobs = await _list_jobs_for_runs_list(
346+
session=session,
347+
run_ids=[r.id for r in run_models],
348+
job_submissions_limit=effective_job_submissions_limit,
349+
include_probes=include_jobs and return_in_api,
350+
)
351+
jobs_by_run = defaultdict(list)
352+
for job in jobs:
353+
jobs_by_run[job.run_id].append(job)
354+
for run_model in run_models:
355+
set_committed_value(run_model, "jobs", jobs_by_run.get(run_model.id, []))
356+
357+
358+
async def _list_jobs_for_runs_list(
359+
session: AsyncSession,
360+
run_ids: List[uuid.UUID],
361+
job_submissions_limit: Optional[int],
362+
include_probes: bool,
363+
) -> List[JobModel]:
364+
options = []
365+
if include_probes:
366+
options.append(joinedload(JobModel.probes))
367+
if job_submissions_limit is None:
368+
res = await session.execute(
369+
select(JobModel)
370+
.where(JobModel.run_id.in_(run_ids))
371+
.options(*options)
372+
.order_by(
373+
JobModel.run_id,
374+
JobModel.replica_num,
375+
JobModel.job_num,
376+
JobModel.submission_num,
377+
)
378+
)
379+
return list(res.unique().scalars().all())
380+
381+
jobs = await _list_latest_jobs_for_runs_list(
382+
session=session,
383+
run_ids=run_ids,
384+
job_submissions_limit=max(job_submissions_limit, 1),
385+
include_probes=include_probes,
386+
)
387+
latest_termination_jobs = await _list_latest_termination_jobs_for_runs_list(
388+
session=session,
389+
run_ids=run_ids,
390+
)
391+
jobs_by_id = {job.id: job for job in jobs}
392+
for job in latest_termination_jobs:
393+
jobs_by_id.setdefault(job.id, job)
394+
return sorted(
395+
jobs_by_id.values(),
396+
key=lambda j: (j.run_id, j.replica_num, j.job_num, j.submission_num),
397+
)
398+
399+
400+
async def _list_latest_jobs_for_runs_list(
401+
session: AsyncSession,
402+
run_ids: List[uuid.UUID],
403+
job_submissions_limit: int,
404+
include_probes: bool,
405+
) -> List[JobModel]:
406+
row_number = (
407+
func.row_number()
408+
.over(
409+
partition_by=(JobModel.run_id, JobModel.replica_num, JobModel.job_num),
410+
order_by=JobModel.submission_num.desc(),
411+
)
412+
.label("row_number")
413+
)
414+
jobs_sq = (
415+
select(
416+
JobModel,
417+
row_number,
418+
)
419+
.where(JobModel.run_id.in_(run_ids))
420+
.subquery()
421+
)
422+
job_alias = aliased(JobModel, jobs_sq)
423+
options = []
424+
if include_probes:
425+
options.append(joinedload(job_alias.probes))
426+
res = await session.execute(
427+
select(job_alias)
428+
.where(jobs_sq.c.row_number <= job_submissions_limit)
429+
.options(*options)
430+
.order_by(
431+
job_alias.run_id,
432+
job_alias.replica_num,
433+
job_alias.job_num,
434+
job_alias.submission_num,
435+
)
436+
)
437+
return list(res.unique().scalars().all())
438+
439+
440+
async def _list_latest_termination_jobs_for_runs_list(
441+
session: AsyncSession,
442+
run_ids: List[uuid.UUID],
443+
) -> List[JobModel]:
444+
row_number = (
445+
func.row_number()
446+
.over(
447+
partition_by=(JobModel.run_id, JobModel.replica_num, JobModel.job_num),
448+
order_by=JobModel.submission_num.desc(),
449+
)
450+
.label("row_number")
451+
)
452+
jobs_sq = (
453+
select(
454+
JobModel,
455+
row_number,
456+
)
457+
.where(
458+
JobModel.run_id.in_(run_ids),
459+
JobModel.termination_reason.isnot(None),
460+
)
461+
.subquery()
462+
)
463+
job_alias = aliased(JobModel, jobs_sq)
464+
res = await session.execute(
465+
select(job_alias)
466+
.where(jobs_sq.c.row_number == 1)
467+
.order_by(
468+
job_alias.run_id,
469+
job_alias.replica_num,
470+
job_alias.job_num,
471+
job_alias.submission_num,
472+
)
473+
)
474+
return list(res.scalars().all())
475+
476+
284477
async def get_run(
285478
session: AsyncSession,
286479
project: ProjectModel,

src/tests/_internal/server/routers/test_runs.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -903,6 +903,7 @@ async def test_limits_job_submissions(
903903
job2 = await create_job(
904904
session=session,
905905
run=run,
906+
submission_num=1,
906907
submitted_at=run_submitted_at,
907908
last_processed_at=run_submitted_at,
908909
)
@@ -930,7 +931,7 @@ async def test_limits_job_submissions(
930931
"job_submissions": [
931932
{
932933
"id": str(job2.id),
933-
"submission_num": 0,
934+
"submission_num": 1,
934935
"deployment_num": 0,
935936
"submitted_at": run_submitted_at.isoformat(),
936937
"last_processed_at": run_submitted_at.isoformat(),
@@ -953,7 +954,7 @@ async def test_limits_job_submissions(
953954
],
954955
"latest_job_submission": {
955956
"id": str(job2.id),
956-
"submission_num": 0,
957+
"submission_num": 1,
957958
"deployment_num": 0,
958959
"submitted_at": run_submitted_at.isoformat(),
959960
"last_processed_at": run_submitted_at.isoformat(),

0 commit comments

Comments
 (0)