skypilot-nightly 1.0.0.dev20250116__py3-none-any.whl → 1.0.0.dev20250118__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
sky/jobs/state.py CHANGED
@@ -107,12 +107,25 @@ def create_table(cursor, conn):
107
107
  db_utils.add_column_to_table(cursor, conn, 'spot', 'local_log_file',
108
108
  'TEXT DEFAULT NULL')
109
109
 
110
- # `job_info` contains the mapping from job_id to the job_name.
111
- # In the future, it may contain more information about each job.
110
+ # `job_info` contains the mapping from job_id to the job_name, as well as
111
+ # information used by the scheduler.
112
112
  cursor.execute("""\
113
113
  CREATE TABLE IF NOT EXISTS job_info (
114
114
  spot_job_id INTEGER PRIMARY KEY AUTOINCREMENT,
115
- name TEXT)""")
115
+ name TEXT,
116
+ schedule_state TEXT,
117
+ controller_pid INTEGER DEFAULT NULL,
118
+ dag_yaml_path TEXT)""")
119
+
120
+ db_utils.add_column_to_table(cursor, conn, 'job_info', 'schedule_state',
121
+ 'TEXT')
122
+
123
+ db_utils.add_column_to_table(cursor, conn, 'job_info', 'controller_pid',
124
+ 'INTEGER DEFAULT NULL')
125
+
126
+ db_utils.add_column_to_table(cursor, conn, 'job_info', 'dag_yaml_path',
127
+ 'TEXT')
128
+
116
129
  conn.commit()
117
130
 
118
131
 
@@ -164,6 +177,9 @@ columns = [
164
177
  # columns from the job_info table
165
178
  '_job_info_job_id', # This should be the same as job_id
166
179
  'job_name',
180
+ 'schedule_state',
181
+ 'controller_pid',
182
+ 'dag_yaml_path',
167
183
  ]
168
184
 
169
185
 
@@ -189,16 +205,18 @@ class ManagedJobStatus(enum.Enum):
189
205
  SUCCEEDED -> SUCCEEDED
190
206
  FAILED -> FAILED
191
207
  FAILED_SETUP -> FAILED_SETUP
208
+ Not all statuses are in this list, since some ManagedJobStatuses are only
209
+ possible while the cluster is INIT/STOPPED/not yet UP.
192
210
  Note that the JobStatus will not be stuck in PENDING, because each cluster
193
211
  is dedicated to a managed job, i.e. there should always be enough resource
194
212
  to run the job and the job will be immediately transitioned to RUNNING.
213
+
195
214
  """
196
215
  # PENDING: Waiting for the jobs controller to have a slot to run the
197
216
  # controller process.
198
- # The submitted_at timestamp of the managed job in the 'spot' table will be
199
- # set to the time when the job is firstly submitted by the user (set to
200
- # PENDING).
201
217
  PENDING = 'PENDING'
218
+ # The submitted_at timestamp of the managed job in the 'spot' table will be
219
+ # set to the time when the job controller begins running.
202
220
  # SUBMITTED: The jobs controller starts the controller process.
203
221
  SUBMITTED = 'SUBMITTED'
204
222
  # STARTING: The controller process is launching the cluster for the managed
@@ -292,14 +310,72 @@ _SPOT_STATUS_TO_COLOR = {
292
310
  }
293
311
 
294
312
 
313
+ class ManagedJobScheduleState(enum.Enum):
314
+ """Captures the state of the job from the scheduler's perspective.
315
+
316
+ A job that predates the introduction of the scheduler will be INVALID.
317
+
318
+ A newly created job will be INACTIVE. The following transitions are valid:
319
+ - INACTIVE -> WAITING: The job is "submitted" to the scheduler, and its job
320
+ controller can be started.
321
+ - WAITING -> LAUNCHING: The job controller is starting by the scheduler and
322
+ may proceed to sky.launch.
323
+ - LAUNCHING -> ALIVE: The launch attempt was completed. It may have
324
+ succeeded or failed. The job controller is not allowed to sky.launch again
325
+ without transitioning to ALIVE_WAITING and then LAUNCHING.
326
+ - ALIVE -> ALIVE_WAITING: The job controller wants to sky.launch again,
327
+ either for recovery or to launch a subsequent task.
328
+ - ALIVE_WAITING -> LAUNCHING: The scheduler has determined that the job
329
+ controller may launch again.
330
+ - LAUNCHING, ALIVE, or ALIVE_WAITING -> DONE: The job controller is exiting
331
+ and the job is in some terminal status. In the future it may be possible
332
+ to transition directly from WAITING or even INACTIVE to DONE if the job is
333
+ cancelled.
334
+
335
+ There is no well-defined mapping from the managed job status to schedule
336
+ state or vice versa. (In fact, schedule state is defined on the job and
337
+ status on the task.)
338
+ - INACTIVE or WAITING should only be seen when a job is PENDING.
339
+ - ALIVE_WAITING should only be seen when a job is RECOVERING, has multiple
340
+ tasks, or needs to retry launching.
341
+ - LAUNCHING and ALIVE can be seen in many different statuses.
342
+ - DONE should only be seen when a job is in a terminal status.
343
+ Since state and status transitions are not atomic, it may be possible to
344
+ briefly observe inconsistent states, like a job that just finished but
345
+ hasn't yet transitioned to DONE.
346
+ """
347
+ # This job may have been created before scheduler was introduced in #4458.
348
+ # This state is not used by scheduler but just for backward compatibility.
349
+ # TODO(cooperc): remove this in v0.11.0
350
+ INVALID = None
351
+ # The job should be ignored by the scheduler.
352
+ INACTIVE = 'INACTIVE'
353
+ # The job is waiting to transition to LAUNCHING for the first time. The
354
+ # scheduler should try to transition it, and when it does, it should start
355
+ # the job controller.
356
+ WAITING = 'WAITING'
357
+ # The job is already alive, but wants to transition back to LAUNCHING,
358
+ # e.g. for recovery, or launching later tasks in the DAG. The scheduler
359
+ # should try to transition it to LAUNCHING.
360
+ ALIVE_WAITING = 'ALIVE_WAITING'
361
+ # The job is running sky.launch, or soon will, using a limited number of
362
+ # allowed launch slots.
363
+ LAUNCHING = 'LAUNCHING'
364
+ # The controller for the job is running, but it's not currently launching.
365
+ ALIVE = 'ALIVE'
366
+ # The job is in a terminal state. (Not necessarily SUCCEEDED.)
367
+ DONE = 'DONE'
368
+
369
+
295
370
  # === Status transition functions ===
296
- def set_job_name(job_id: int, name: str):
371
+ def set_job_info(job_id: int, name: str):
297
372
  with db_utils.safe_cursor(_DB_PATH) as cursor:
298
373
  cursor.execute(
299
374
  """\
300
375
  INSERT INTO job_info
301
- (spot_job_id, name)
302
- VALUES (?, ?)""", (job_id, name))
376
+ (spot_job_id, name, schedule_state)
377
+ VALUES (?, ?, ?)""",
378
+ (job_id, name, ManagedJobScheduleState.INACTIVE.value))
303
379
 
304
380
 
305
381
  def set_pending(job_id: int, task_id: int, task_name: str, resources_str: str):
@@ -324,7 +400,7 @@ def set_submitted(job_id: int, task_id: int, run_timestamp: str,
324
400
  job_id: The managed job ID.
325
401
  task_id: The task ID.
326
402
  run_timestamp: The run_timestamp of the run. This will be used to
327
- determine the log directory of the managed task.
403
+ determine the log directory of the managed task.
328
404
  submit_time: The time when the managed task is submitted.
329
405
  resources_str: The resources string of the managed task.
330
406
  specs: The specs of the managed task.
@@ -458,13 +534,12 @@ def set_failed(
458
534
  with db_utils.safe_cursor(_DB_PATH) as cursor:
459
535
  previous_status = cursor.execute(
460
536
  'SELECT status FROM spot WHERE spot_job_id=(?)',
461
- (job_id,)).fetchone()
462
- previous_status = ManagedJobStatus(previous_status[0])
463
- if previous_status in [ManagedJobStatus.RECOVERING]:
464
- # If the job is recovering, we should set the
465
- # last_recovered_at to the end_time, so that the
466
- # end_at - last_recovered_at will not be affect the job duration
467
- # calculation.
537
+ (job_id,)).fetchone()[0]
538
+ previous_status = ManagedJobStatus(previous_status)
539
+ if previous_status == ManagedJobStatus.RECOVERING:
540
+ # If the job is recovering, we should set the last_recovered_at to
541
+ # the end_time, so that the end_at - last_recovered_at will not be
542
+ # affect the job duration calculation.
468
543
  fields_to_set['last_recovered_at'] = end_time
469
544
  set_str = ', '.join(f'{k}=(?)' for k in fields_to_set)
470
545
  task_str = '' if task_id is None else f' AND task_id={task_id}'
@@ -564,6 +639,44 @@ def get_nonterminal_job_ids_by_name(name: Optional[str]) -> List[int]:
564
639
  return job_ids
565
640
 
566
641
 
642
+ def get_schedule_live_jobs(job_id: Optional[int]) -> List[Dict[str, Any]]:
643
+ """Get jobs from the database that have a live schedule_state.
644
+
645
+ This should return job(s) that are not INACTIVE, WAITING, or DONE. So a
646
+ returned job should correspond to a live job controller process, with one
647
+ exception: the job may have just transitioned from WAITING to LAUNCHING, but
648
+ the controller process has not yet started.
649
+ """
650
+ job_filter = '' if job_id is None else 'AND spot_job_id=(?)'
651
+ job_value = (job_id,) if job_id is not None else ()
652
+
653
+ # Join spot and job_info tables to get the job name for each task.
654
+ # We use LEFT OUTER JOIN mainly for backward compatibility, as for an
655
+ # existing controller before #1982, the job_info table may not exist,
656
+ # and all the managed jobs created before will not present in the
657
+ # job_info.
658
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
659
+ rows = cursor.execute(
660
+ f"""\
661
+ SELECT spot_job_id, schedule_state, controller_pid
662
+ FROM job_info
663
+ WHERE schedule_state not in (?, ?, ?)
664
+ {job_filter}
665
+ ORDER BY spot_job_id DESC""",
666
+ (ManagedJobScheduleState.INACTIVE.value,
667
+ ManagedJobScheduleState.WAITING.value,
668
+ ManagedJobScheduleState.DONE.value, *job_value)).fetchall()
669
+ jobs = []
670
+ for row in rows:
671
+ job_dict = {
672
+ 'job_id': row[0],
673
+ 'schedule_state': ManagedJobScheduleState(row[1]),
674
+ 'controller_pid': row[2],
675
+ }
676
+ jobs.append(job_dict)
677
+ return jobs
678
+
679
+
567
680
  def get_all_job_ids_by_name(name: Optional[str]) -> List[int]:
568
681
  """Get all job ids by name."""
569
682
  name_filter = ''
@@ -672,6 +785,8 @@ def get_managed_jobs(job_id: Optional[int] = None) -> List[Dict[str, Any]]:
672
785
  for row in rows:
673
786
  job_dict = dict(zip(columns, row))
674
787
  job_dict['status'] = ManagedJobStatus(job_dict['status'])
788
+ job_dict['schedule_state'] = ManagedJobScheduleState(
789
+ job_dict['schedule_state'])
675
790
  if job_dict['job_name'] is None:
676
791
  job_dict['job_name'] = job_dict['task_name']
677
792
  jobs.append(job_dict)
@@ -723,3 +838,128 @@ def get_local_log_file(job_id: int, task_id: Optional[int]) -> Optional[str]:
723
838
  f'SELECT local_log_file FROM spot '
724
839
  f'WHERE {filter_str}', filter_args).fetchone()
725
840
  return local_log_file[-1] if local_log_file else None
841
+
842
+
843
+ # === Scheduler state functions ===
844
+ # Only the scheduler should call these functions. They may require holding the
845
+ # scheduler lock to work correctly.
846
+
847
+
848
+ def scheduler_set_waiting(job_id: int, dag_yaml_path: str) -> None:
849
+ """Do not call without holding the scheduler lock."""
850
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
851
+ updated_count = cursor.execute(
852
+ 'UPDATE job_info SET '
853
+ 'schedule_state = (?), dag_yaml_path = (?) '
854
+ 'WHERE spot_job_id = (?) AND schedule_state = (?)',
855
+ (ManagedJobScheduleState.WAITING.value, dag_yaml_path, job_id,
856
+ ManagedJobScheduleState.INACTIVE.value)).rowcount
857
+ assert updated_count == 1, (job_id, updated_count)
858
+
859
+
860
+ def scheduler_set_launching(job_id: int,
861
+ current_state: ManagedJobScheduleState) -> None:
862
+ """Do not call without holding the scheduler lock."""
863
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
864
+ updated_count = cursor.execute(
865
+ 'UPDATE job_info SET '
866
+ 'schedule_state = (?) '
867
+ 'WHERE spot_job_id = (?) AND schedule_state = (?)',
868
+ (ManagedJobScheduleState.LAUNCHING.value, job_id,
869
+ current_state.value)).rowcount
870
+ assert updated_count == 1, (job_id, updated_count)
871
+
872
+
873
+ def scheduler_set_alive(job_id: int) -> None:
874
+ """Do not call without holding the scheduler lock."""
875
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
876
+ updated_count = cursor.execute(
877
+ 'UPDATE job_info SET '
878
+ 'schedule_state = (?) '
879
+ 'WHERE spot_job_id = (?) AND schedule_state = (?)',
880
+ (ManagedJobScheduleState.ALIVE.value, job_id,
881
+ ManagedJobScheduleState.LAUNCHING.value)).rowcount
882
+ assert updated_count == 1, (job_id, updated_count)
883
+
884
+
885
+ def scheduler_set_alive_waiting(job_id: int) -> None:
886
+ """Do not call without holding the scheduler lock."""
887
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
888
+ updated_count = cursor.execute(
889
+ 'UPDATE job_info SET '
890
+ 'schedule_state = (?) '
891
+ 'WHERE spot_job_id = (?) AND schedule_state = (?)',
892
+ (ManagedJobScheduleState.ALIVE_WAITING.value, job_id,
893
+ ManagedJobScheduleState.ALIVE.value)).rowcount
894
+ assert updated_count == 1, (job_id, updated_count)
895
+
896
+
897
+ def scheduler_set_done(job_id: int, idempotent: bool = False) -> None:
898
+ """Do not call without holding the scheduler lock."""
899
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
900
+ updated_count = cursor.execute(
901
+ 'UPDATE job_info SET '
902
+ 'schedule_state = (?) '
903
+ 'WHERE spot_job_id = (?) AND schedule_state != (?)',
904
+ (ManagedJobScheduleState.DONE.value, job_id,
905
+ ManagedJobScheduleState.DONE.value)).rowcount
906
+ if not idempotent:
907
+ assert updated_count == 1, (job_id, updated_count)
908
+
909
+
910
+ def set_job_controller_pid(job_id: int, pid: int):
911
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
912
+ updated_count = cursor.execute(
913
+ 'UPDATE job_info SET '
914
+ 'controller_pid = (?) '
915
+ 'WHERE spot_job_id = (?)', (pid, job_id)).rowcount
916
+ assert updated_count == 1, (job_id, updated_count)
917
+
918
+
919
+ def get_job_schedule_state(job_id: int) -> ManagedJobScheduleState:
920
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
921
+ state = cursor.execute(
922
+ 'SELECT schedule_state FROM job_info WHERE spot_job_id = (?)',
923
+ (job_id,)).fetchone()[0]
924
+ return ManagedJobScheduleState(state)
925
+
926
+
927
+ def get_num_launching_jobs() -> int:
928
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
929
+ return cursor.execute(
930
+ 'SELECT COUNT(*) '
931
+ 'FROM job_info '
932
+ 'WHERE schedule_state = (?)',
933
+ (ManagedJobScheduleState.LAUNCHING.value,)).fetchone()[0]
934
+
935
+
936
+ def get_num_alive_jobs() -> int:
937
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
938
+ return cursor.execute(
939
+ 'SELECT COUNT(*) '
940
+ 'FROM job_info '
941
+ 'WHERE schedule_state IN (?, ?, ?)',
942
+ (ManagedJobScheduleState.ALIVE_WAITING.value,
943
+ ManagedJobScheduleState.LAUNCHING.value,
944
+ ManagedJobScheduleState.ALIVE.value)).fetchone()[0]
945
+
946
+
947
+ def get_waiting_job() -> Optional[Dict[str, Any]]:
948
+ """Get the next job that should transition to LAUNCHING.
949
+
950
+ Backwards compatibility note: jobs submitted before #4485 will have no
951
+ schedule_state and will be ignored by this SQL query.
952
+ """
953
+ with db_utils.safe_cursor(_DB_PATH) as cursor:
954
+ row = cursor.execute(
955
+ 'SELECT spot_job_id, schedule_state, dag_yaml_path '
956
+ 'FROM job_info '
957
+ 'WHERE schedule_state in (?, ?) '
958
+ 'ORDER BY spot_job_id LIMIT 1',
959
+ (ManagedJobScheduleState.WAITING.value,
960
+ ManagedJobScheduleState.ALIVE_WAITING.value)).fetchone()
961
+ return {
962
+ 'job_id': row[0],
963
+ 'schedule_state': ManagedJobScheduleState(row[1]),
964
+ 'dag_yaml_path': row[2],
965
+ } if row is not None else None