11import itertools
22import math
33import uuid
4+ from collections import defaultdict
45from collections .abc import Iterable
56from datetime import datetime , timezone
67from typing import List , Optional
910from apscheduler .triggers .cron import CronTrigger
1011from sqlalchemy import and_ , func , or_ , select , update
1112from 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
1416import dstack ._internal .utils .common as common_utils
1517from dstack ._internal .core .errors import (
3739 RunTerminationReason ,
3840 ServiceSpec ,
3941)
42+ from dstack ._internal .core .models .users import GlobalRole
4043from dstack ._internal .core .services .diff import format_diff_fields_for_event
4144from dstack ._internal .server .db import get_db , is_db_postgres , is_db_sqlite
4245from dstack ._internal .server .models import (
4346 FleetModel ,
4447 JobModel ,
48+ MemberModel ,
4549 ProbeModel ,
4650 ProjectModel ,
4751 RepoModel ,
6367from dstack ._internal .server .services .pipelines import PipelineHinterProtocol
6468from dstack ._internal .server .services .plugins import apply_plugin_policies
6569from dstack ._internal .server .services .probes import is_probe_ready
66- from dstack ._internal .server .services .projects import list_user_project_models
6770from 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+
223254async 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+
284477async def get_run (
285478 session : AsyncSession ,
286479 project : ProjectModel ,
0 commit comments