experiments: support exp run --checkpoint for baseline commits (#4701)

* handle untracked files when `git reset --hard` in experiments workspace

* error on checkpoint branch already exists

* terminate stage process on checkpoint errors

* update tests

* cleanup error message
This commit is contained in:
Peter Rowlands (변기호)
2020-10-13 23:10:08 +03:00
committed by GitHub
parent 3e011d70d5
commit cc8d0ef43f
6 changed files with 44 additions and 18 deletions
+17 -2
View File
@@ -173,6 +173,7 @@ class Experiments:
def _scm_checkout(self, rev):
self.scm.repo.git.reset(hard=True)
self.scm.repo.git.clean(force=True)
if self.scm.repo.head.is_detached:
self._checkout_default_branch()
if not Git.is_sha(rev) or not self.scm.has_rev(rev):
@@ -185,6 +186,8 @@ class Experiments:
# switch to default branch
git_repo = self.scm.repo
git_repo.git.reset(hard=True)
git_repo.git.clean(force=True)
origin_refs = git_repo.remotes["origin"].refs
# origin/HEAD will point to tip of the default branch unless we
@@ -309,14 +312,23 @@ class Experiments:
self, exp_hash, check_exists=True, create_branch=True, checkpoint=False
):
"""Commit stages as an experiment and return the commit SHA."""
if not self.scm.is_dirty():
if not self.scm.is_dirty(untracked_files=True):
raise UnchangedExperimentError(self.scm.get_rev())
rev = self.scm.get_rev()
checkpoint = "-checkpoint" if checkpoint else ""
exp_name = f"{rev[:7]}-{exp_hash}{checkpoint}"
if create_branch:
if check_exists and exp_name in self.scm.list_branches():
if (
check_exists or checkpoint
) and exp_name in self.scm.list_branches():
if checkpoint:
raise DvcException(
f"Checkpoint experiment containing '{rev[:7]}' "
"already exists. To resume the experiment, "
"run:\n\n"
f"\tdvc exp run --continue {rev[:7]}"
)
logger.debug("Using existing experiment branch '%s'", exp_name)
return self.scm.resolve_rev(exp_name)
self.scm.checkout(exp_name, create_new=True)
@@ -696,6 +708,9 @@ class Experiments:
def _check_baseline(self, exp_rev):
baseline_sha = self.repo.scm.get_rev()
if exp_rev == baseline_sha:
return exp_rev
exp_baseline = self._get_baseline(exp_rev)
if exp_baseline is None:
# if we can't tell from branch name, fall back to parent commit
+2 -2
View File
@@ -288,8 +288,8 @@ class Git(Base):
def is_tracked(self, path):
return bool(self.repo.git.ls_files(path))
def is_dirty(self):
return self.repo.is_dirty()
def is_dirty(self, **kwargs):
return self.repo.is_dirty(**kwargs)
def active_branch(self):
return self.repo.active_branch.name
+22 -11
View File
@@ -39,9 +39,9 @@ def warn_if_fish(executable):
@unlocked_repo
def cmd_run(stage, *args, checkpoint=False, **kwargs):
def cmd_run(stage, *args, checkpoint_func=None, **kwargs):
kwargs = {"cwd": stage.wdir, "env": fix_env(None), "close_fds": True}
if checkpoint:
if checkpoint_func:
# indicate that checkpoint cmd is being run inside DVC
kwargs["env"].update({"DVC_CHECKPOINT": "1"})
@@ -78,7 +78,9 @@ def cmd_run(stage, *args, checkpoint=False, **kwargs):
p = subprocess.Popen(cmd, **kwargs)
if main_thread:
old_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
p.communicate()
with checkpoint_monitor(stage, checkpoint_func, p):
p.communicate()
finally:
if old_handler:
signal.signal(signal.SIGINT, old_handler)
@@ -106,8 +108,7 @@ def run_stage(stage, dry=False, force=False, checkpoint_func=None, **kwargs):
)
logger.info("\t%s", stage.cmd)
if not dry:
with checkpoint_monitor(stage, checkpoint_func) as monitor:
cmd_run(stage, checkpoint=monitor is not None)
cmd_run(stage, checkpoint_func=checkpoint_func)
class CheckpointCond:
@@ -126,14 +127,17 @@ class CheckpointCond:
@contextmanager
def checkpoint_monitor(stage, callback_func):
def checkpoint_monitor(stage, callback_func, proc):
if not callback_func:
yield None
return
logger.debug(
"Monitoring checkpoint stage '%s' with cmd process '%s'", stage, proc
)
done_cond = CheckpointCond()
monitor_thread = threading.Thread(
target=_checkpoint_run, args=(stage, callback_func, done_cond),
target=_checkpoint_run, args=(stage, callback_func, done_cond, proc),
)
try:
@@ -144,14 +148,21 @@ def checkpoint_monitor(stage, callback_func):
monitor_thread.join()
def _checkpoint_run(stage, callback_func, done_cond):
def _checkpoint_run(stage, callback_func, done_cond, proc):
"""Run callback_func whenever checkpoint signal file is present."""
signal_path = os.path.join(stage.repo.tmp_dir, CHECKPOINT_SIGNAL_FILE)
while True:
if os.path.exists(signal_path):
_run_callback(stage, callback_func)
logger.debug("Remove checkpoint signal file")
os.remove(signal_path)
try:
_run_callback(stage, callback_func)
except Exception: # pylint: disable=broad-except
logger.exception(
"Error generating checkpoint, %s will be aborted", stage
)
proc.terminate()
finally:
logger.debug("Remove checkpoint signal file")
os.remove(signal_path)
if done_cond.wait(1):
return
+1 -1
View File
@@ -1302,4 +1302,4 @@ def test_repro_when_cmd_changes(tmp_dir, dvc, run_copy, mocker):
stage.addressing: ["changed checksum"]
}
assert dvc.reproduce(stage.addressing)[0] == stage
m.assert_called_once_with(stage, checkpoint=False)
m.assert_called_once_with(stage, checkpoint_func=None)
+1 -1
View File
@@ -286,7 +286,7 @@ def test_repro_when_cmd_changes(tmp_dir, dvc, run_copy, mocker):
assert dvc.status([target]) == {target: ["changed command"]}
assert dvc.reproduce(target)[0] == stage
m.assert_called_once_with(stage, checkpoint=False)
m.assert_called_once_with(stage, checkpoint_func=None)
def test_repro_when_new_deps_is_added_in_dvcfile(tmp_dir, dvc, run_copy):
+1 -1
View File
@@ -288,4 +288,4 @@ def test_stage_run_checkpoint(tmp_dir, dvc, mocker, checkpoint):
else:
callback = None
run_stage(stage, checkpoint_func=callback)
mock_cmd_run.assert_called_with(stage, checkpoint=checkpoint)
mock_cmd_run.assert_called_with(stage, checkpoint_func=callback)