Skip to content

Commit a692ce4

Browse files
committed
Restore live conformer jobs by name on restart, not the fossil 'conformer{i}'
The contract: a running conformer job lives in self.running_jobs under the same name the live path emits, '{job_type}_{i}' (e.g. 'conf_opt_0'). Every consumer parses that format -- get_i_from_job_name strips the 'conf_opt'/ 'conf_sp' prefix, and get_completed_incore_jobs routes on it into job_dict[label]['conf_opt'][i]. How it broke: restore_running_jobs emitted the fossil 'conformer{i}' instead. get_i_from_job_name returns None for it, so get_completed_incore_jobs fell into its fallback branch, derived an empty job-type from the underscore-less name ('conformer0'.split('_')[:-1] == []), and died with KeyError: '' on the first scheduling sweep -- crashing every ARC restart that had a live conformer job. The fix emits '{job_type}_{i}' from the same expression the live path uses, rather than a second hard-coded literal that must be kept in sync by hand. The job_dict was already reconstructed correctly (conf_opt keyed by int index), so the name was the only defect. Also corrects get_i_from_job_name's docstring, which still advertised the retired 'conformer12' format. The test drives a restart payload carrying a live conf_opt job through the real restore_running_jobs + get_completed_incore_jobs path: red with KeyError: '' on the unfixed code, green after.
1 parent d285b1e commit a692ce4

3 files changed

Lines changed: 79 additions & 3 deletions

File tree

arc/checks/common.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ def get_i_from_job_name(job_name: str) -> int | None:
4343
Get the conformer or tsg index from the job name.
4444
4545
Args:
46-
job_name (str): The job name, e.g., 'conformer12' or 'tsg5'.
46+
job_name (str): The job name, e.g., 'conf_opt_12', 'conf_sp_3', or 'tsg5'.
4747
4848
Returns:
4949
int | None: The corresponding conformer or tsg index.

arc/scheduler.py

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4060,7 +4060,9 @@ def restore_running_jobs(self):
40604060
and ('tsg' not in job_description or job_description['tsg'] is None):
40614061
self.running_jobs[spc_label].append(job_description['job_name'])
40624062
elif 'conformer' in job_description:
4063-
self.running_jobs[spc_label].append(f'conformer{job_description["conformer"]}')
4063+
# Emit the same '{job_type}_{conformer}' name the live path uses (e.g. 'conf_opt_0'),
4064+
# not the fossil 'conformer{i}' that no consumer of running_jobs accepts.
4065+
self.running_jobs[spc_label].append(f'{job_description["job_type"]}_{job_description["conformer"]}')
40644066
elif 'tsg' in job_description:
40654067
self.running_jobs[spc_label].append(f'tsg{job_description["tsg"]}')
40664068
for species in self.species_list:
@@ -4091,9 +4093,16 @@ def restore_running_jobs(self):
40914093
and ('tsg' not in job_description or job_description['tsg'] is None):
40924094
self.job_dict[spc_label][job_description['job_type']][job_description['job_name']] = job
40934095
elif 'conformer' in job_description and job_description['conformer'] is not None:
4096+
# File the job under its actual job_type ('conf_opt' or 'conf_sp'), the same
4097+
# key the live path uses (see run_job) and the same key get_completed_incore_jobs
4098+
# reads back -- filing a conf_sp job under 'conf_opt' would crash the first sweep
4099+
# with KeyError: 'conf_sp'.
4100+
conf_job_type = job_description['job_type']
40944101
if 'conf_opt' not in self.job_dict[spc_label].keys():
40954102
self.job_dict[spc_label]['conf_opt'] = dict()
4096-
self.job_dict[spc_label]['conf_opt'][int(job_description['conformer'])] = job
4103+
if conf_job_type == 'conf_sp' and 'conf_sp' not in self.job_dict[spc_label].keys():
4104+
self.job_dict[spc_label]['conf_sp'] = dict()
4105+
self.job_dict[spc_label][conf_job_type][int(job_description['conformer'])] = job
40974106
# don't generate additional conformers for this species
40984107
self.dont_gen_confs.append(spc_label)
40994108
elif 'tsg' in job_description and job_description['tsg'] is not None:

arc/scheduler_test.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,73 @@ def test_conformers(self):
240240
self.assertEqual(lines[11], '\n')
241241
self.assertEqual(lines[12], 'SMILES: CC\n')
242242

243+
def test_restore_running_jobs_conformer_reconnects(self):
244+
"""Restarting with a live conformer job must reconnect to it, not crash.
245+
246+
Regression for the restore-path job-name contract. During normal operation a running
247+
conformer job is stored in ``running_jobs`` as ``'{job_type}_{i}'`` (e.g. ``'conf_opt_0'``),
248+
and every consumer of ``running_jobs`` parses that format. ``restore_running_jobs`` used to
249+
emit the fossil ``'conformer{i}'`` instead, which ``get_i_from_job_name`` returns ``None``
250+
for; the first scheduling sweep after a restart (``get_completed_incore_jobs``) then fell
251+
into its fallback branch, derived an empty job-type from the underscore-less name, and died
252+
with ``KeyError: ''``. This drives a restart payload carrying a live conformer job through
253+
the real restore + sweep path and asserts the reconnection instead of the crash.
254+
"""
255+
label = 'methylamine'
256+
xyz = """C -0.57422867 -0.01669771 0.01229213
257+
N 0.82084044 0.08279104 -0.37769346
258+
H -1.05737005 -0.84067772 -0.52007494
259+
H -1.10211468 0.90879867 -0.23383011
260+
H -0.66133128 -0.19490562 1.08785111
261+
H 0.88047852 0.26966160 -1.37780789
262+
H 1.27889520 -0.81548721 -0.22940984"""
263+
spc = ARCSpecies(label=label, smiles='CN', xyz=xyz)
264+
sched = Scheduler(project='project_test_restore_conf', ess_settings=self.ess_settings,
265+
species_list=[spc], composite_method=None,
266+
conformer_opt_level=Level(repr=default_levels_of_theory['conformer']),
267+
opt_level=Level(repr=default_levels_of_theory['opt']),
268+
freq_level=Level(repr=default_levels_of_theory['freq']),
269+
sp_level=Level(repr=default_levels_of_theory['sp']),
270+
scan_level=Level(repr=default_levels_of_theory['scan']),
271+
ts_guess_level=Level(repr=default_levels_of_theory['ts_guesses']),
272+
project_directory=self.project_directory, testing=True,
273+
job_types=self.job_types1,
274+
orbitals_level=default_levels_of_theory['orbitals'], adaptive_levels=None)
275+
# Two live conformer jobs -- a conf_opt and a conf_sp -- serialized exactly as ARC writes
276+
# them into the restart file. conf_sp jobs can equally be in flight during a restart, and are
277+
# routed differently on read-back (get_completed_incore_jobs reads job_dict[label]['conf_sp']).
278+
conf_opt_job = job_factory(job_adapter='gaussian', project='project_test_restore_conf',
279+
ess_settings=self.ess_settings, species=[spc], xyz=xyz,
280+
job_type='conf_opt', conformer=0,
281+
level=Level(repr={'method': 'wb97xd', 'basis': 'def2svp'}),
282+
project_directory=self.project_directory, job_num=901)
283+
conf_sp_job = job_factory(job_adapter='gaussian', project='project_test_restore_conf',
284+
ess_settings=self.ess_settings, species=[spc], xyz=xyz,
285+
job_type='conf_sp', conformer=0,
286+
level=Level(repr={'method': 'wb97xd', 'basis': 'def2svp'}),
287+
project_directory=self.project_directory, job_num=902)
288+
sched.restart_dict = {'running_jobs': {label: [conf_opt_job.as_dict(), conf_sp_job.as_dict()]}}
289+
sched.running_jobs = dict()
290+
sched.job_dict = dict()
291+
292+
sched.restore_running_jobs()
293+
# Each conformer job is filed under its own job_type keyed by its integer index -- a conf_sp
294+
# job under 'conf_sp', not 'conf_opt'. Filing conf_sp under 'conf_opt' would crash the sweep
295+
# below with KeyError: 'conf_sp'.
296+
self.assertIn('conf_opt', sched.job_dict[label])
297+
self.assertIn(0, sched.job_dict[label]['conf_opt'])
298+
self.assertIn('conf_sp', sched.job_dict[label])
299+
self.assertIn(0, sched.job_dict[label]['conf_sp'])
300+
301+
# The first scheduling sweep after a restart reproduces the production crash on the unfixed
302+
# code: get_i_from_job_name('conformer0') is None, the fallback derives an empty job-type
303+
# from the underscore-less name, and self.job_dict[label][''] raises KeyError: ''.
304+
sched.get_completed_incore_jobs()
305+
self.assertEqual(sched.completed_incore_jobs, list())
306+
307+
# And the restored names are the live '{job_type}_{i}' format, not the fossil 'conformer{i}'.
308+
self.assertEqual(sched.running_jobs[label], ['conf_opt_0', 'conf_sp_0'])
309+
243310
def test_check_negative_freq(self):
244311
"""Test the check_negative_freq() method"""
245312
label = 'C2H6'

0 commit comments

Comments
 (0)