Coverage for tests/test_lssthtc.py: 99%

850 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-16 08:59 +0000

1# This file is part of ctrl_bps_htcondor. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

9# This software is dual licensed under the GNU General Public License and also 

10# under a 3-clause BSD license. Recipients may choose which of these licenses 

11# to use; please see the files gpl-3.0.txt and/or bsd_license.txt, 

12# respectively. If you choose the GPL option then the following text applies 

13# (but note that there is still no warranty even if you opt for BSD instead): 

14# 

15# This program is free software: you can redistribute it and/or modify 

16# it under the terms of the GNU General Public License as published by 

17# the Free Software Foundation, either version 3 of the License, or 

18# (at your option) any later version. 

19# 

20# This program is distributed in the hope that it will be useful, 

21# but WITHOUT ANY WARRANTY; without even the implied warranty of 

22# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

23# GNU General Public License for more details. 

24# 

25# You should have received a copy of the GNU General Public License 

26# along with this program. If not, see <https://www.gnu.org/licenses/>. 

27"""Unit tests for classes and functions in lssthtc.py.""" 

28 

29import io 

30import logging 

31import os 

32import pathlib 

33import stat 

34import tempfile 

35import unittest 

36from shutil import copy2, copytree, ignore_patterns, rmtree, which 

37from unittest.mock import patch 

38 

39from htcondor2 import JobStatus 

40 

41from lsst.ctrl.bps import BpsConfig 

42from lsst.ctrl.bps.bps_utils import chdir 

43from lsst.ctrl.bps.htcondor import dagman_configurator, htcondor_config, lssthtc 

44from lsst.daf.butler import Config 

45from lsst.utils.tests import temporaryDirectory 

46 

47from .dag_test_utils import make_lazy_dag 

48 

49logger = logging.getLogger("lsst.ctrl.bps.htcondor") 

50TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

51 

52 

53class TestLsstHtc(unittest.TestCase): 

54 """Test basic usage.""" 

55 

56 def testHtcEscapeInt(self): 

57 self.assertEqual(lssthtc.htc_escape(100), 100) 

58 

59 def testHtcEscapeDouble(self): 

60 self.assertEqual(lssthtc.htc_escape('"double"'), '""double""') 

61 

62 def testHtcEscapeSingle(self): 

63 self.assertEqual(lssthtc.htc_escape("'single'"), "''single''") 

64 

65 def testHtcEscapeNoSideEffect(self): 

66 val = "'val'" 

67 self.assertEqual(lssthtc.htc_escape(val), "''val''") 

68 self.assertEqual(val, "'val'") 

69 

70 def testHtcEscapeQuot(self): 

71 self.assertEqual(lssthtc.htc_escape("&quot;val&quot;"), '"val"') 

72 

73 

74class HtcTweakJobInfoTestCase(unittest.TestCase): 

75 """Test the function responsible for massaging job information.""" 

76 

77 def setUp(self): 

78 self.log_dir = tempfile.TemporaryDirectory() 

79 self.log_dirname = pathlib.Path(self.log_dir.name) 

80 self.job = { 

81 "Cluster": 1, 

82 "Proc": 0, 

83 "Iwd": str(self.log_dirname), 

84 "Owner": self.log_dirname.owner(), 

85 "MyType": None, 

86 "TerminatedNormally": True, 

87 } 

88 

89 def tearDown(self): 

90 self.log_dir.cleanup() 

91 

92 def testDirectAssignments(self): 

93 lssthtc.htc_tweak_log_info(self.log_dirname, self.job) 

94 self.assertEqual(self.job["ClusterId"], self.job["Cluster"]) 

95 self.assertEqual(self.job["ProcId"], self.job["Proc"]) 

96 self.assertEqual(self.job["Iwd"], str(self.log_dirname)) 

97 self.assertEqual(self.job["Owner"], self.log_dirname.owner()) 

98 

99 def testIncompatibleAdPassThru(self): 

100 # Passing a job ad with insufficient information should be a no-op. 

101 expected = {"foo": "bar"} 

102 result = dict(expected) 

103 lssthtc.htc_tweak_log_info(self.log_dirname, result) 

104 self.assertEqual(result, expected) 

105 

106 def testJobStatusAssignmentJobAbortedEvent(self): 

107 job = self.job | {"MyType": "JobAbortedEvent"} 

108 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

109 self.assertTrue("JobStatus" in job) 

110 self.assertEqual(job["JobStatus"], JobStatus.REMOVED) 

111 

112 def testJobStatusAssignmentExecuteEvent(self): 

113 job = self.job | {"MyType": "ExecuteEvent"} 

114 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

115 self.assertTrue("JobStatus" in job) 

116 self.assertEqual(job["JobStatus"], JobStatus.RUNNING) 

117 

118 def testJobStatusAssignmentSubmitEvent(self): 

119 job = self.job | {"MyType": "SubmitEvent"} 

120 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

121 self.assertTrue("JobStatus" in job) 

122 self.assertEqual(job["JobStatus"], JobStatus.IDLE) 

123 

124 def testJobStatusAssignmentJobHeldEvent(self): 

125 job = self.job | {"MyType": "JobHeldEvent"} 

126 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

127 self.assertTrue("JobStatus" in job) 

128 self.assertEqual(job["JobStatus"], JobStatus.HELD) 

129 

130 def testJobStatusAssignmentJobTerminatedEvent(self): 

131 job = self.job | {"MyType": "JobTerminatedEvent"} 

132 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

133 self.assertTrue("JobStatus" in job) 

134 self.assertEqual(job["JobStatus"], JobStatus.COMPLETED) 

135 

136 def testJobStatusAssignmentPostScriptTerminatedEvent(self): 

137 job = self.job | {"MyType": "PostScriptTerminatedEvent"} 

138 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

139 self.assertTrue("JobStatus" in job) 

140 self.assertEqual(job["JobStatus"], JobStatus.COMPLETED) 

141 

142 def testJobStatusAssignmentReleaseEventMainDagJob(self): 

143 job = self.job | {"MyType": "JobReleaseEvent"} 

144 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

145 self.assertTrue("JobStatus" in job) 

146 self.assertEqual(job["JobStatus"], JobStatus.RUNNING) 

147 

148 def testJobStatusAssignmentReleaseEventForNodeJob(self): 

149 job = self.job | {"MyType": "JobReleaseEvent", "DAGNodeName": "test_payload_job"} 

150 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

151 self.assertTrue("JobStatus" in job) 

152 self.assertEqual(job["JobStatus"], None) 

153 

154 def testAddingExitStatusSuccess(self): 

155 job = self.job | { 

156 "MyType": "JobTerminatedEvent", 

157 "ExitBySignal": False, 

158 "ReturnValue": 1, 

159 } 

160 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

161 self.assertIn("ExitBySignal", job) 

162 self.assertIs(job["ExitBySignal"], False) 

163 self.assertIn("ExitCode", job) 

164 self.assertEqual(job["ExitCode"], 1) 

165 

166 def testAddingExitStatusFailure(self): 

167 job = self.job | { 

168 "MyType": "JobHeldEvent", 

169 } 

170 with self.assertLogs(logger=logger, level="ERROR") as cm: 

171 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

172 self.assertIn("Could not determine exit status", cm.output[0]) 

173 

174 def testLoggingUnknownLogEvent(self): 

175 job = self.job | {"MyType": "Foo"} 

176 with self.assertLogs(logger=logger, level="DEBUG") as cm: 

177 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

178 self.assertIn("Unknown log event", cm.output[1]) 

179 

180 def testMissingKey(self): 

181 job = self.job 

182 del job["Cluster"] 

183 with self.assertRaises(KeyError) as cm: 

184 lssthtc.htc_tweak_log_info(self.log_dirname, job) 

185 self.assertEqual(str(cm.exception), "'Cluster'") 

186 

187 

188class HtcCheckDagmanOutputTestCase(unittest.TestCase): 

189 """Test htc_check_dagman_output function.""" 

190 

191 def test_missing_output_file(self): 

192 with temporaryDirectory() as tmp_dir: 

193 with self.assertRaises(FileNotFoundError): 

194 _ = lssthtc.htc_check_dagman_output(tmp_dir) 

195 

196 def test_permissions_output_file(self): 

197 with temporaryDirectory() as tmp_dir: 

198 copy2(f"{TESTDIR}/data/test_tmpdir_abort.dag.dagman.out", tmp_dir) 

199 os.chmod(f"{tmp_dir}/test_tmpdir_abort.dag.dagman.out", 0o200) 

200 print(os.stat(f"{tmp_dir}/test_tmpdir_abort.dag.dagman.out")) 

201 results = lssthtc.htc_check_dagman_output(tmp_dir) 

202 os.chmod(f"{tmp_dir}/test_tmpdir_abort.dag.dagman.out", 0o600) 

203 self.assertIn("Could not read dagman output file", results) 

204 

205 def test_submit_failure(self): 

206 with temporaryDirectory() as tmp_dir: 

207 copy2(f"{TESTDIR}/data/bad_submit.dag.dagman.out", tmp_dir) 

208 results = lssthtc.htc_check_dagman_output(tmp_dir) 

209 self.assertIn("Warn: Job submission issues (last: ", results) 

210 

211 def test_tmpdir_abort(self): 

212 with temporaryDirectory() as tmp_dir: 

213 copy2(f"{TESTDIR}/data/test_tmpdir_abort.dag.dagman.out", tmp_dir) 

214 results = lssthtc.htc_check_dagman_output(tmp_dir) 

215 self.assertIn("Cannot submit from /tmp", results) 

216 

217 def test_no_messages(self): 

218 with temporaryDirectory() as tmp_dir: 

219 copy2(f"{TESTDIR}/data/test_no_messages.dag.dagman.out", tmp_dir) 

220 results = lssthtc.htc_check_dagman_output(tmp_dir) 

221 self.assertEqual("", results) 

222 

223 def test_missing_warning(self): 

224 with temporaryDirectory() as tmp_dir: 

225 with open(os.path.join(tmp_dir, "test_missing_warning.dag.dagman.out"), "w") as fh: 

226 print( 

227 "01/01/26 01:01:01 ERROR: Warning is fatal error because of DAGMAN_USE_STRICT setting", 

228 file=fh, 

229 ) 

230 results = lssthtc.htc_check_dagman_output(tmp_dir) 

231 self.assertIn("Missing warning", results) 

232 

233 

234class SummarizeDagTestCase(unittest.TestCase): 

235 """Test summarize_dag function.""" 

236 

237 def test_no_dag_file(self): 

238 with temporaryDirectory() as tmp_dir: 

239 summary, job_name_to_pipetask, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

240 self.assertFalse(len(job_name_to_pipetask)) 

241 self.assertFalse(len(job_name_to_type)) 

242 self.assertFalse(summary) 

243 

244 def test_success(self): 

245 with temporaryDirectory() as tmp_dir: 

246 copy2(f"{TESTDIR}/data/good.dag", tmp_dir) 

247 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

248 self.assertEqual(summary, "pipetaskInit:1;label1:1;label2:1;label3:1;finalJob:1") 

249 self.assertEqual( 

250 job_name_to_label, 

251 { 

252 "pipetaskInit": "pipetaskInit", 

253 "0682f8f9-12f0-40a5-971e-8b30c7231e5c_label1_val1_val2": "label1", 

254 "d0305e2d-f164-4a85-bd24-06afe6c84ed9_label2_val1_val2": "label2", 

255 "2806ecc9-1bba-4362-8fff-ab4e6abb9f83_label3_val1_val2": "label3", 

256 "finalJob": "finalJob", 

257 }, 

258 ) 

259 self.assertEqual( 

260 job_name_to_type, 

261 { 

262 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

263 "0682f8f9-12f0-40a5-971e-8b30c7231e5c_label1_val1_val2": lssthtc.WmsNodeType.PAYLOAD, 

264 "d0305e2d-f164-4a85-bd24-06afe6c84ed9_label2_val1_val2": lssthtc.WmsNodeType.PAYLOAD, 

265 "2806ecc9-1bba-4362-8fff-ab4e6abb9f83_label3_val1_val2": lssthtc.WmsNodeType.PAYLOAD, 

266 "finalJob": lssthtc.WmsNodeType.FINAL, 

267 }, 

268 ) 

269 

270 def test_service(self): 

271 with temporaryDirectory() as tmp_dir: 

272 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag", tmp_dir) 

273 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

274 self.assertEqual(summary, "pipetaskInit:1;label1:2;label2:2;finalJob:1") 

275 self.assertEqual( 

276 job_name_to_label, 

277 { 

278 "pipetaskInit": "pipetaskInit", 

279 "057c8caf-66f6-4612-abf7-cdea5b666b1b_label1_val1a_val2b": "label1", 

280 "4a7f478b-2e9b-435c-a730-afac3f621658_label1_val1a_val2a": "label1", 

281 "40040b97-606d-4997-98d3-e0493055fe7e_label2_val1a_val2b": "label2", 

282 "696ee50d-e711-40d6-9caf-ee29ae4a656d_label2_val1a_val2a": "label2", 

283 "finalJob": "finalJob", 

284 "provisioningJob": "provisioningJob", 

285 }, 

286 ) 

287 self.assertEqual( 

288 job_name_to_type, 

289 { 

290 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

291 "057c8caf-66f6-4612-abf7-cdea5b666b1b_label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

292 "4a7f478b-2e9b-435c-a730-afac3f621658_label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

293 "40040b97-606d-4997-98d3-e0493055fe7e_label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

294 "696ee50d-e711-40d6-9caf-ee29ae4a656d_label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

295 "finalJob": lssthtc.WmsNodeType.FINAL, 

296 "provisioningJob": lssthtc.WmsNodeType.SERVICE, 

297 }, 

298 ) 

299 

300 def test_noop(self): 

301 with temporaryDirectory() as tmp_dir: 

302 copy2(f"{TESTDIR}/data/noop_running_1/noop_running_1.dag", tmp_dir) 

303 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(tmp_dir) 

304 self.assertEqual( 

305 set(summary.split(";")), 

306 {"pipetaskInit:1", "label1:6", "label2:6", "label3:6", "label4:6", "label5:6", "finalJob:1"}, 

307 ) 

308 self.assertEqual( 

309 job_name_to_label, 

310 { 

311 "label1_val1a_val2a": "label1", 

312 "label1_val1a_val2b": "label1", 

313 "label1_val1b_val2a": "label1", 

314 "label1_val1b_val2b": "label1", 

315 "label1_val1c_val2a": "label1", 

316 "label1_val1c_val2b": "label1", 

317 "label2_val1a_val2a": "label2", 

318 "label2_val1a_val2b": "label2", 

319 "label2_val1b_val2a": "label2", 

320 "label2_val1b_val2b": "label2", 

321 "label2_val1c_val2a": "label2", 

322 "label2_val1c_val2b": "label2", 

323 "label3_val1a_val2a": "label3", 

324 "label3_val1a_val2b": "label3", 

325 "label3_val1b_val2a": "label3", 

326 "label3_val1b_val2b": "label3", 

327 "label3_val1c_val2a": "label3", 

328 "label3_val1c_val2b": "label3", 

329 "label4_val1a_val2a": "label4", 

330 "label4_val1a_val2b": "label4", 

331 "label4_val1b_val2a": "label4", 

332 "label4_val1b_val2b": "label4", 

333 "label4_val1c_val2a": "label4", 

334 "label4_val1c_val2b": "label4", 

335 "label5_val1a_val2a": "label5", 

336 "label5_val1a_val2b": "label5", 

337 "label5_val1b_val2a": "label5", 

338 "label5_val1b_val2b": "label5", 

339 "label5_val1c_val2a": "label5", 

340 "label5_val1c_val2b": "label5", 

341 "finalJob": "finalJob", 

342 "pipetaskInit": "pipetaskInit", 

343 "wms_noop_order1_val1a": "order1", 

344 "wms_noop_order1_val1b": "order1", 

345 }, 

346 ) 

347 self.assertEqual( 

348 job_name_to_type, 

349 { 

350 "label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

351 "label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

352 "label1_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

353 "label1_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

354 "label1_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

355 "label1_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

356 "label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

357 "label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

358 "label2_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

359 "label2_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

360 "label2_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

361 "label2_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

362 "label3_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

363 "label3_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

364 "label3_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

365 "label3_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

366 "label3_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

367 "label3_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

368 "label4_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

369 "label4_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

370 "label4_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

371 "label4_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

372 "label4_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

373 "label4_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

374 "label5_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

375 "label5_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

376 "label5_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

377 "label5_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

378 "label5_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

379 "label5_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

380 "finalJob": lssthtc.WmsNodeType.FINAL, 

381 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

382 "wms_noop_order1_val1a": lssthtc.WmsNodeType.NOOP, 

383 "wms_noop_order1_val1b": lssthtc.WmsNodeType.NOOP, 

384 }, 

385 ) 

386 

387 def test_subdags(self): 

388 self.maxDiff = None 

389 with temporaryDirectory() as tmp_dir: 

390 submit_dir = os.path.join(tmp_dir, "group_running_1") 

391 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

392 summary, job_name_to_label, job_name_to_type = lssthtc.summarize_dag(submit_dir) 

393 self.assertEqual( 

394 set(summary.split(";")), 

395 {"pipetaskInit:1", "label1:6", "label2:6", "label3:6", "label4:6", "label5:6", "finalJob:1"}, 

396 ) 

397 

398 self.assertEqual( 

399 job_name_to_label, 

400 { 

401 "pipetaskInit": "pipetaskInit", 

402 "label1_val1b_val2a": "label1", 

403 "label1_val1c_val2a": "label1", 

404 "label1_val1a_val2b": "label1", 

405 "label1_val1b_val2b": "label1", 

406 "label1_val1c_val2b": "label1", 

407 "label1_val1a_val2a": "label1", 

408 "label2_val1a_val2b": "label2", 

409 "label2_val1a_val2a": "label2", 

410 "label2_val1b_val2a": "label2", 

411 "label2_val1b_val2b": "label2", 

412 "label2_val1c_val2a": "label2", 

413 "label2_val1c_val2b": "label2", 

414 "label3_val1b_val2a": "label3", 

415 "label3_val1c_val2a": "label3", 

416 "label3_val1a_val2b": "label3", 

417 "label3_val1b_val2b": "label3", 

418 "label3_val1c_val2b": "label3", 

419 "label3_val1a_val2a": "label3", 

420 "label4_val1a_val2b": "label4", 

421 "label4_val1a_val2a": "label4", 

422 "label4_val1b_val2a": "label4", 

423 "label4_val1b_val2b": "label4", 

424 "label4_val1c_val2a": "label4", 

425 "label4_val1c_val2b": "label4", 

426 "label5_val1a_val2b": "label5", 

427 "label5_val1a_val2a": "label5", 

428 "label5_val1b_val2a": "label5", 

429 "label5_val1b_val2b": "label5", 

430 "label5_val1c_val2a": "label5", 

431 "label5_val1c_val2b": "label5", 

432 "finalJob": "finalJob", 

433 "provisioningJob": "provisioningJob", 

434 "wms_group_order1_val1a": "order1", 

435 "wms_group_order1_val1b": "order1", 

436 "wms_group_order1_val1c": "order1", 

437 "wms_check_status_wms_group_order1_val1a": "order1", 

438 "wms_check_status_wms_group_order1_val1b": "order1", 

439 "wms_check_status_wms_group_order1_val1c": "order1", 

440 }, 

441 ) 

442 

443 self.assertEqual( 

444 job_name_to_type, 

445 { 

446 "pipetaskInit": lssthtc.WmsNodeType.PAYLOAD, 

447 "label1_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

448 "label1_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

449 "label1_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

450 "label1_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

451 "label1_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

452 "label1_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

453 "label2_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

454 "label2_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

455 "label2_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

456 "label2_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

457 "label2_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

458 "label2_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

459 "label3_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

460 "label3_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

461 "label3_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

462 "label3_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

463 "label3_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

464 "label3_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

465 "label4_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

466 "label4_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

467 "label4_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

468 "label4_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

469 "label4_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

470 "label4_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

471 "label5_val1a_val2b": lssthtc.WmsNodeType.PAYLOAD, 

472 "label5_val1a_val2a": lssthtc.WmsNodeType.PAYLOAD, 

473 "label5_val1b_val2a": lssthtc.WmsNodeType.PAYLOAD, 

474 "label5_val1b_val2b": lssthtc.WmsNodeType.PAYLOAD, 

475 "label5_val1c_val2a": lssthtc.WmsNodeType.PAYLOAD, 

476 "label5_val1c_val2b": lssthtc.WmsNodeType.PAYLOAD, 

477 "finalJob": lssthtc.WmsNodeType.FINAL, 

478 "provisioningJob": lssthtc.WmsNodeType.SERVICE, 

479 "wms_group_order1_val1a": lssthtc.WmsNodeType.SUBDAG, 

480 "wms_group_order1_val1b": lssthtc.WmsNodeType.SUBDAG, 

481 "wms_group_order1_val1c": lssthtc.WmsNodeType.SUBDAG, 

482 "wms_check_status_wms_group_order1_val1a": lssthtc.WmsNodeType.SUBDAG_CHECK, 

483 "wms_check_status_wms_group_order1_val1b": lssthtc.WmsNodeType.SUBDAG_CHECK, 

484 "wms_check_status_wms_group_order1_val1c": lssthtc.WmsNodeType.SUBDAG_CHECK, 

485 }, 

486 ) 

487 

488 

489class ReadDagNodesLogTestCase(unittest.TestCase): 

490 """Test read_dag_nodes_log function.""" 

491 

492 def setUp(self): 

493 self.tmpdir = tempfile.mkdtemp() 

494 

495 def tearDown(self): 

496 rmtree(self.tmpdir, ignore_errors=True) 

497 

498 def testFileMissing(self): 

499 with self.assertRaisesRegex(FileNotFoundError, "DAGMan node log not found in"): 

500 _ = lssthtc.read_dag_nodes_log(self.tmpdir) 

501 

502 def testRegular(self): 

503 with temporaryDirectory() as tmp_dir: 

504 submit_dir = os.path.join(tmp_dir, "tiny_problems") 

505 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

506 results = lssthtc.read_dag_nodes_log(submit_dir) 

507 self.assertEqual(results["9231.0"]["Cluster"], 9231) 

508 self.assertEqual(results["9231.0"]["Proc"], 0) 

509 self.assertEqual(results["9231.0"]["ReturnValue"], 1) 

510 self.assertEqual(len(results), 6) 

511 

512 def testSubdags(self): 

513 """Making sure it gets data from subdag dirs and doesn't 

514 fail if some subdags haven't started running yet. 

515 """ 

516 with temporaryDirectory() as tmp_dir: 

517 submit_dir = os.path.join(tmp_dir, "group_running_1") 

518 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

519 results = lssthtc.read_dag_nodes_log(submit_dir) 

520 # main dag 

521 self.assertEqual(results["10094.0"]["Cluster"], 10094) 

522 # subdag 

523 self.assertEqual(results["10112.0"]["Cluster"], 10112) 

524 self.assertEqual(results["10116.0"]["Cluster"], 10116) 

525 

526 

527class ReadNodeStatusTestCase(unittest.TestCase): 

528 """Test read_node_status function.""" 

529 

530 def setUp(self): 

531 self.tmpdir = tempfile.mkdtemp() 

532 

533 def tearDown(self): 

534 rmtree(self.tmpdir, ignore_errors=True) 

535 

536 def testServiceJobNotSubmitted(self): 

537 # tiny_prov_no_submit files have successful workflow 

538 # but provisioningJob could not submit. 

539 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.dag.nodes.log", self.tmpdir) 

540 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.dag.dagman.log", self.tmpdir) 

541 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.node_status", self.tmpdir) 

542 copy2(f"{TESTDIR}/data/tiny_prov_no_submit/tiny_prov_no_submit.dag", self.tmpdir) 

543 

544 jobs = lssthtc.read_node_status(self.tmpdir) 

545 found = [ 

546 id_ 

547 for id_ in jobs 

548 if jobs[id_].get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN) == lssthtc.WmsNodeType.SERVICE 

549 ] 

550 self.assertEqual(len(found), 1) 

551 self.assertEqual(jobs[found[0]]["DAGNodeName"], "provisioningJob") 

552 self.assertEqual(jobs[found[0]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY) 

553 

554 def testMissingStatusFile(self): 

555 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag.nodes.log", self.tmpdir) 

556 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag.dagman.log", self.tmpdir) 

557 copy2(f"{TESTDIR}/data/tiny_problems/tiny_problems.dag", self.tmpdir) 

558 

559 jobs = lssthtc.read_node_status(self.tmpdir) 

560 self.assertEqual(len(jobs), 7) 

561 self.assertEqual(jobs["9230.0"]["DAGNodeName"], "pipetaskInit") 

562 self.assertEqual(jobs["9230.0"]["wms_node_type"], lssthtc.WmsNodeType.PAYLOAD) 

563 found = [ 

564 id_ 

565 for id_ in jobs 

566 if jobs[id_].get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN) == lssthtc.WmsNodeType.SERVICE 

567 ] 

568 self.assertEqual(len(found), 1) 

569 self.assertEqual(jobs[found[0]]["DAGNodeName"], "provisioningJob") 

570 

571 def testSubdagsRunning(self): 

572 with temporaryDirectory() as tmp_dir: 

573 test_tmp_dir = pathlib.Path(tmp_dir) 

574 submit_dir = test_tmp_dir / "submit" 

575 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

576 jobs = lssthtc.read_node_status(submit_dir) 

577 self.assertEqual(len(jobs), 39) # includes non-payload jobs 

578 # not guaranteed ids are same, so use names instead 

579 job_name_to_id = {} 

580 for id_, info in jobs.items(): 

581 job_name_to_id[info.get("DAGNodeName", id_)] = id_ 

582 job_type_to_names = {} 

583 for id_, info in jobs.items(): 

584 job_type_to_names.setdefault( 

585 info.get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN), set() 

586 ).add(info.get("DAGNodeName", id_)) 

587 

588 # check counts 

589 self.assertNotIn(lssthtc.WmsNodeType.NOOP, job_type_to_names) 

590 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.PAYLOAD]), 31) 

591 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.FINAL]), 1) 

592 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SERVICE]), 1) 

593 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG]), 3) 

594 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG_CHECK]), 3) 

595 

596 # spot check some statuses 

597 self.assertEqual( 

598 jobs[job_name_to_id["label3_val1a_val2b"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

599 ) 

600 self.assertEqual( 

601 jobs[job_name_to_id["wms_group_order1_val1a"]]["NodeStatus"], lssthtc.NodeStatus.SUBMITTED 

602 ) 

603 self.assertEqual( 

604 jobs[job_name_to_id["label5_val1a_val2a"]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY 

605 ) 

606 self.assertEqual( 

607 jobs[job_name_to_id["label2_val1a_val2a"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

608 ) 

609 

610 def testSubdagsFailed(self): 

611 with temporaryDirectory() as tmp_dir: 

612 test_tmp_dir = pathlib.Path(tmp_dir) 

613 submit_dir = test_tmp_dir / "submit" 

614 copytree(f"{TESTDIR}/data/group_failed_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

615 jobs = lssthtc.read_node_status(submit_dir) 

616 self.assertEqual(len(jobs), 39) 

617 # not guaranteed ids are same, so use names instead 

618 job_name_to_id = {} 

619 for id_, info in jobs.items(): 

620 job_name_to_id[info.get("DAGNodeName", id_)] = id_ 

621 job_type_to_names = {} 

622 for id_, info in jobs.items(): 

623 job_type_to_names.setdefault( 

624 info.get("wms_node_type", lssthtc.WmsNodeType.UNKNOWN), set() 

625 ).add(info.get("DAGNodeName", id_)) 

626 

627 # check counts 

628 self.assertNotIn(lssthtc.WmsNodeType.NOOP, job_type_to_names) 

629 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.PAYLOAD]), 31) 

630 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.FINAL]), 1) 

631 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SERVICE]), 1) 

632 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG]), 3) 

633 self.assertEqual(len(job_type_to_names[lssthtc.WmsNodeType.SUBDAG_CHECK]), 3) 

634 

635 # spot check some statuses 

636 self.assertEqual( 

637 jobs[job_name_to_id["label3_val1a_val2b"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

638 ) 

639 self.assertEqual( 

640 jobs[job_name_to_id["wms_group_order1_val1a"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

641 ) 

642 self.assertEqual( 

643 jobs[job_name_to_id["label5_val1a_val2a"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

644 ) 

645 

646 self.assertEqual( 

647 jobs[job_name_to_id["label5_val1b_val2a"]]["NodeStatus"], lssthtc.NodeStatus.FUTILE 

648 ) 

649 self.assertEqual( 

650 jobs[job_name_to_id["wms_group_order1_val1b"]]["NodeStatus"], lssthtc.NodeStatus.DONE 

651 ) 

652 self.assertEqual( 

653 jobs[job_name_to_id["wms_check_status_wms_group_order1_val1b"]]["NodeStatus"], 

654 lssthtc.NodeStatus.ERROR, 

655 ) 

656 

657 

658class ReadSingleNodeStatusTestCase(unittest.TestCase): 

659 """Test read_single_node_status function.""" 

660 

661 def setUp(self): 

662 self.tmpdir = tempfile.mkdtemp() 

663 

664 def tearDown(self): 

665 rmtree(self.tmpdir, ignore_errors=True) 

666 

667 def _copyFiles(self, data_subdir, suffixes): 

668 """Copy files with given suffixes from tests/data/<data_subdir>/.""" 

669 for suffix in suffixes: 

670 copy2(f"{TESTDIR}/data/{data_subdir}/{data_subdir}{suffix}", self.tmpdir) 

671 

672 def _jobNameToId(self, jobs): 

673 return {info["DAGNodeName"]: id_ for id_, info in jobs.items()} 

674 

675 def testAllDone(self): 

676 self._copyFiles( 

677 "tiny_success", 

678 [".dag", ".dag.dagman.log", ".dag.nodes.log", ".node_status"], 

679 ) 

680 filename = pathlib.Path(self.tmpdir) / "tiny_success.node_status" 

681 jobs = lssthtc.read_single_node_status(filename, -1) 

682 

683 self.assertEqual(len(jobs), 5) 

684 name_to_id = self._jobNameToId(jobs) 

685 

686 # All four submitted nodes are marked DONE. 

687 for name in [ 

688 "pipetaskInit", 

689 "5bba27bd-8df7-4668-a9c5-e911192c5cdb_label1_val1_val2", 

690 "0b225f1f-6edf-4380-b546-76c97947a88f_label2_val1_val2", 

691 "finalJob", 

692 ]: 

693 self.assertIn(name, name_to_id, msg=f"Missing job {name}") 

694 self.assertEqual( 

695 jobs[name_to_id[name]]["NodeStatus"], 

696 lssthtc.NodeStatus.DONE, 

697 msg=f"Expected DONE for {name}", 

698 ) 

699 

700 # Service job not tracked by node_status; it came from the event log so 

701 # it has a real positive ClusterId but no NodeStatus field. 

702 self.assertIn("provisioningJob", name_to_id) 

703 self.assertGreater(jobs[name_to_id["provisioningJob"]]["ClusterId"], 0) 

704 

705 # Spot-check labels and types. 

706 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["bps_job_label"], "pipetaskInit") 

707 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["wms_node_type"], lssthtc.WmsNodeType.PAYLOAD) 

708 self.assertEqual(jobs[name_to_id["finalJob"]]["wms_node_type"], lssthtc.WmsNodeType.FINAL) 

709 self.assertEqual(jobs[name_to_id["provisioningJob"]]["wms_node_type"], lssthtc.WmsNodeType.SERVICE) 

710 

711 # DAGManJobID is populated from the dagman log for every job. 

712 for job in jobs.values(): 

713 self.assertIn("DAGManJobID", job) 

714 

715 def testMixedStatuses(self): 

716 self._copyFiles( 

717 "tiny_problems", 

718 [".dag", ".dag.dagman.log", ".dag.nodes.log", ".node_status"], 

719 ) 

720 filename = pathlib.Path(self.tmpdir) / "tiny_problems.node_status" 

721 jobs = lssthtc.read_single_node_status(filename, -1) 

722 

723 self.assertEqual(len(jobs), 7) 

724 name_to_id = self._jobNameToId(jobs) 

725 

726 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["NodeStatus"], lssthtc.NodeStatus.DONE) 

727 self.assertEqual( 

728 jobs[name_to_id["057c8caf-66f6-4612-abf7-cdea5b666b1b_label1_val1a_val2b"]]["NodeStatus"], 

729 lssthtc.NodeStatus.ERROR, 

730 ) 

731 self.assertEqual( 

732 jobs[name_to_id["4a7f478b-2e9b-435c-a730-afac3f621658_label1_val1a_val2a"]]["NodeStatus"], 

733 lssthtc.NodeStatus.DONE, 

734 ) 

735 self.assertEqual( 

736 jobs[name_to_id["40040b97-606d-4997-98d3-e0493055fe7e_label2_val1a_val2b"]]["NodeStatus"], 

737 lssthtc.NodeStatus.FUTILE, 

738 ) 

739 self.assertEqual(jobs[name_to_id["finalJob"]]["NodeStatus"], lssthtc.NodeStatus.ERROR) 

740 # Service job not tracked by node_status; came from event log so 

741 # it has a real positive ClusterId but no NodeStatus field. 

742 self.assertIn("provisioningJob", name_to_id) 

743 self.assertGreater(jobs[name_to_id["provisioningJob"]]["ClusterId"], 0) 

744 

745 def testRunningWorkflow(self): 

746 self._copyFiles( 

747 "tiny_running", 

748 [".dag", ".dag.dagman.log", ".dag.nodes.log", ".node_status"], 

749 ) 

750 filename = pathlib.Path(self.tmpdir) / "tiny_running.node_status" 

751 jobs = lssthtc.read_single_node_status(filename, -1) 

752 

753 self.assertEqual(len(jobs), 5) 

754 name_to_id = self._jobNameToId(jobs) 

755 

756 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["NodeStatus"], lssthtc.NodeStatus.DONE) 

757 self.assertEqual( 

758 jobs[name_to_id["ca27ea57-c014-44c1-838a-78c06bc3ec1b_label1_val1_val2"]]["NodeStatus"], 

759 lssthtc.NodeStatus.SUBMITTED, 

760 ) 

761 self.assertEqual( 

762 jobs[name_to_id["dbf919fa-5453-4b05-8806-ad6390fda0a3_label2_val1_val2"]]["NodeStatus"], 

763 lssthtc.NodeStatus.NOT_READY, 

764 ) 

765 self.assertEqual(jobs[name_to_id["finalJob"]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY) 

766 # Service job appeared in the event log; has a real positive ClusterId. 

767 self.assertIn("provisioningJob", name_to_id) 

768 self.assertGreater(jobs[name_to_id["provisioningJob"]]["ClusterId"], 0) 

769 

770 def testMissingNodeStatusFile(self): 

771 # Omit the .node_status file; jobs must be built from the event log 

772 # and dag. 

773 self._copyFiles( 

774 "tiny_problems", 

775 [".dag", ".dag.dagman.log", ".dag.nodes.log"], 

776 ) 

777 filename = pathlib.Path(self.tmpdir) / "tiny_problems.node_status" 

778 jobs = lssthtc.read_single_node_status(filename, -1) 

779 

780 self.assertEqual(len(jobs), 7) 

781 name_to_id = self._jobNameToId(jobs) 

782 

783 # Jobs that appeared in the event log have real (positive) cluster IDs. 

784 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["DAGNodeName"], "pipetaskInit") 

785 self.assertGreater(jobs[name_to_id["pipetaskInit"]]["ClusterId"], 0) 

786 

787 # The service job appeared in the event log and has a real positive ID. 

788 self.assertGreater(jobs[name_to_id["provisioningJob"]]["ClusterId"], 0) 

789 

790 # All jobs carry the correct label and type from the dag file. 

791 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["wms_node_type"], lssthtc.WmsNodeType.PAYLOAD) 

792 self.assertEqual(jobs[name_to_id["provisioningJob"]]["wms_node_type"], lssthtc.WmsNodeType.SERVICE) 

793 

794 def testMissingLogFiles(self): 

795 # Omit both log files; every job should get a fake negative ID. 

796 self._copyFiles("tiny_success", [".dag", ".node_status"]) 

797 filename = pathlib.Path(self.tmpdir) / "tiny_success.node_status" 

798 jobs = lssthtc.read_single_node_status(filename, -1) 

799 

800 self.assertEqual(len(jobs), 5) 

801 for job in jobs.values(): 

802 self.assertLess(job["ClusterId"], 0) 

803 self.assertEqual(job["DAGManJobID"], lssthtc.MISSING_ID) 

804 

805 # NodeStatus values from the node_status file must still be preserved. 

806 name_to_id = self._jobNameToId(jobs) 

807 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["NodeStatus"], lssthtc.NodeStatus.DONE) 

808 self.assertEqual(jobs[name_to_id["finalJob"]]["NodeStatus"], lssthtc.NodeStatus.DONE) 

809 self.assertEqual(jobs[name_to_id["provisioningJob"]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY) 

810 

811 def testPermissionLogFiles(self): 

812 # Check what happens if can't read dag log file. 

813 self._copyFiles("tiny_success", [".dag", ".node_status", ".dag.dagman.log"]) 

814 filename = os.path.join(self.tmpdir, "tiny_success.dag.dagman.log") 

815 current_mode = os.stat(filename).st_mode 

816 no_read_mode = current_mode & ~stat.S_IRUSR & ~stat.S_IRGRP & ~stat.S_IROTH 

817 os.chmod(filename, no_read_mode) 

818 

819 filename = pathlib.Path(self.tmpdir) / "tiny_success.node_status" 

820 jobs = lssthtc.read_single_node_status(filename, -1) 

821 

822 self.assertEqual(len(jobs), 5) 

823 for job in jobs.values(): 

824 self.assertLess(job["ClusterId"], 0) 

825 self.assertEqual(job["DAGManJobID"], lssthtc.MISSING_ID) 

826 

827 # NodeStatus values from the node_status file must still be preserved. 

828 name_to_id = self._jobNameToId(jobs) 

829 self.assertEqual(jobs[name_to_id["pipetaskInit"]]["NodeStatus"], lssthtc.NodeStatus.DONE) 

830 self.assertEqual(jobs[name_to_id["finalJob"]]["NodeStatus"], lssthtc.NodeStatus.DONE) 

831 self.assertEqual(jobs[name_to_id["provisioningJob"]]["NodeStatus"], lssthtc.NodeStatus.NOT_READY) 

832 

833 def testInitFakeId(self): 

834 # Verify fake IDs count down from the given starting value. 

835 self._copyFiles("tiny_success", [".dag", ".node_status"]) 

836 filename = pathlib.Path(self.tmpdir) / "tiny_success.node_status" 

837 init_fake_id = -10 

838 jobs = lssthtc.read_single_node_status(filename, init_fake_id) 

839 

840 self.assertEqual(len(jobs), 5) 

841 cluster_ids = [job["ClusterId"] for job in jobs.values()] 

842 # All IDs must be at most init_fake_id (i.e., -10 or lower). 

843 for cid in cluster_ids: 

844 self.assertLessEqual(cid, init_fake_id) 

845 # All IDs must be unique. 

846 self.assertEqual(len(set(cluster_ids)), len(cluster_ids)) 

847 

848 def testFromDagJobAttribute(self): 

849 self._copyFiles( 

850 "tiny_success", 

851 [".dag", ".dag.dagman.log", ".dag.nodes.log", ".node_status"], 

852 ) 

853 filename = pathlib.Path(self.tmpdir) / "tiny_success.node_status" 

854 jobs = lssthtc.read_single_node_status(filename, -1) 

855 

856 for id_, job in jobs.items(): 

857 self.assertEqual( 

858 job["from_dag_job"], 

859 "wms_tiny_success", 

860 msg=f"Job {id_} has wrong from_dag_job", 

861 ) 

862 

863 def testServiceJobPlaceholder(self): 

864 self._copyFiles( 

865 "tiny_prov_no_submit", 

866 [".dag", ".dag.dagman.log", ".dag.nodes.log", ".node_status"], 

867 ) 

868 filename = pathlib.Path(self.tmpdir) / "tiny_prov_no_submit.node_status" 

869 jobs = lssthtc.read_single_node_status(filename, -1) 

870 

871 service_jobs = [ 

872 (id_, info) 

873 for id_, info in jobs.items() 

874 if info.get("wms_node_type") == lssthtc.WmsNodeType.SERVICE 

875 ] 

876 self.assertEqual(len(service_jobs), 1) 

877 service_id, service_job = service_jobs[0] 

878 self.assertEqual(service_job["DAGNodeName"], "provisioningJob") 

879 self.assertEqual(service_job["NodeStatus"], lssthtc.NodeStatus.NOT_READY) 

880 self.assertLess(service_job["ClusterId"], 0) 

881 

882 

883class HTCJobTestCase(unittest.TestCase): 

884 """Test HTCJob methods.""" 

885 

886 def testWriteDagCommandsPayload(self): 

887 job = lssthtc.HTCJob( 

888 "job1", 

889 "label1", 

890 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

891 {"dir": "jobs/label1"}, 

892 ) 

893 job.subfile = "job1.sub" 

894 

895 mockfh = io.StringIO() 

896 job.write_dag_commands(mockfh, "../..") 

897 self.assertIn('JOB job1 "job1.sub" DIR "../../jobs/label1"', mockfh.getvalue()) 

898 

899 def testWriteDagCommandsNotJob(self): 

900 # Testing giving command_name, no dag_rel_path and no dir 

901 job = lssthtc.HTCJob( 

902 "finalJob", 

903 "finalJob", 

904 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

905 ) 

906 job.subfile = "jobs/finalJob/finalJob.sub" 

907 mockfh = io.StringIO() 

908 job.write_dag_commands(mockfh, "", "FINAL") 

909 self.assertIn('FINAL finalJob "jobs/finalJob/finalJob.sub"', mockfh.getvalue()) 

910 

911 def testWriteDagCommandsNoop(self): 

912 job = lssthtc.HTCJob("wms_noop_job1", "label1", {}, {"noop": True}) 

913 job.subfile = "notthere.sub" 

914 mockfh = io.StringIO() 

915 job.write_dag_commands(mockfh, "") 

916 self.assertIn("NOOP", mockfh.getvalue()) 

917 

918 def testWriteSubmitFile(self): 

919 job = lssthtc.HTCJob( 

920 "job1", 

921 "label1", 

922 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

923 ) 

924 with temporaryDirectory() as tmp_dir: 

925 filename = pathlib.Path(tmp_dir) / "label1/job1.sub" 

926 job.write_submit_file(filename.parent) 

927 self.assertTrue(filename.exists()) 

928 # Try to make Submit object from file to find any syntax issues 

929 _ = lssthtc.htc_create_submit_from_file(filename) 

930 

931 def testWriteSubmitFileExists(self): 

932 job = lssthtc.HTCJob( 

933 "job1", 

934 "label1", 

935 {"executable": "/bin/sleep", "arguments": "60", "log": "job1.log"}, 

936 ) 

937 with temporaryDirectory() as tmp_dir: 

938 filename = pathlib.Path(tmp_dir) / "job1.sub" 

939 job.subfile = filename 

940 with open(filename, "w"): 

941 pass # make empty file 

942 job.write_submit_file(filename.parent) 

943 # make sure didn't overwrite file 

944 self.assertEqual(filename.stat().st_size, 0, "Incorrectly overwrote existing file") 

945 

946 

947class HtcWriteJobCommands(unittest.TestCase): 

948 """Test _htc_write_job_commands function.""" 

949 

950 def testAllCommands(self): 

951 dag_cmds = { 

952 "pre": { 

953 "defer": {"status": 1, "time": 120}, 

954 "debug": {"filename": "debug_pre.txt", "type": "ALL"}, 

955 "executable": "exec1", 

956 "arguments": "arg1 arg2", 

957 }, 

958 "post": { 

959 "defer": {"status": 2, "time": 180}, 

960 "debug": {"filename": "debug_post.txt", "type": "ALL"}, 

961 "executable": "exec2", 

962 "arguments": "arg3 arg4", 

963 }, 

964 "vars": {"num": 8, "spaces": "a space"}, 

965 "pre_skip": "1", 

966 "retry": 3, 

967 "retry_unless_exit": 1, 

968 "abort_dag_on": {"node_exit": 100, "abort_exit": 4}, 

969 "priority": 123, 

970 } 

971 

972 truth = """SCRIPT DEFER 1 120 DEBUG debug_pre.txt ALL PRE job1 exec1 arg1 arg2 

973SCRIPT DEFER 2 180 DEBUG debug_post.txt ALL POST job1 exec2 arg3 arg4 

974VARS job1 num="8" 

975VARS job1 spaces="a space" 

976PRE_SKIP job1 1 

977RETRY job1 3 UNLESS-EXIT 1 

978ABORT-DAG-ON job1 100 RETURN 4 

979PRIORITY job1 123 

980""" 

981 mockfh = io.StringIO() 

982 lssthtc._htc_write_job_commands(mockfh, "job1", dag_cmds) 

983 self.assertEqual(mockfh.getvalue(), truth) 

984 

985 def testPartialCommands(self): 

986 # Trigger skipping the inner if clauses. 

987 dag_cmds = { 

988 "pre": { 

989 "executable": "exec1", 

990 }, 

991 "post": { 

992 "executable": "exec2", 

993 }, 

994 "vars": {"num": 8, "spaces": "a space"}, 

995 "pre_skip": "1", 

996 "retry": 3, 

997 } 

998 

999 truth = """SCRIPT PRE job1 exec1 

1000SCRIPT POST job1 exec2 

1001VARS job1 num="8" 

1002VARS job1 spaces="a space" 

1003PRE_SKIP job1 1 

1004RETRY job1 3 

1005""" 

1006 mockfh = io.StringIO() 

1007 lssthtc._htc_write_job_commands(mockfh, "job1", dag_cmds) 

1008 self.assertEqual(mockfh.getvalue(), truth) 

1009 

1010 def testNoCommands(self): 

1011 dag_cmds = {} 

1012 mockfh = io.StringIO() 

1013 lssthtc._htc_write_job_commands(mockfh, "job2", dag_cmds) 

1014 self.assertEqual(mockfh.getvalue(), "") 

1015 

1016 def testFinal(self): 

1017 self.maxDiff = None 

1018 dag_cmds = { 

1019 "pre": { 

1020 "defer": {"status": 1, "time": 120}, 

1021 "debug": {"filename": "debug_pre.txt", "type": "ALL"}, 

1022 "executable": "exec1", 

1023 "arguments": "arg1 arg2", 

1024 }, 

1025 "post": { 

1026 "defer": {"status": 2, "time": 180}, 

1027 "debug": {"filename": "debug_post.txt", "type": "ALL"}, 

1028 "executable": "exec2", 

1029 "arguments": "arg3 arg4", 

1030 }, 

1031 "vars": {"num": 8, "spaces": "a space"}, 

1032 "pre_skip": "1", 

1033 "retry": 3, 

1034 "retry_unless_exit": 1, 

1035 "abort_dag_on": {"node_exit": 100, "abort_exit": 4}, 

1036 "priority": 123, 

1037 } 

1038 

1039 truth = """SCRIPT DEFER 1 120 DEBUG debug_pre.txt ALL PRE finalJob exec1 arg1 arg2 

1040SCRIPT DEFER 2 180 DEBUG debug_post.txt ALL POST finalJob exec2 arg3 arg4 

1041VARS finalJob num="8" 

1042VARS finalJob spaces="a space" 

1043PRE_SKIP finalJob 1 

1044""" 

1045 mockfh = io.StringIO() 

1046 lssthtc._htc_write_job_commands(mockfh, "finalJob", dag_cmds, "FINAL") 

1047 self.assertEqual(mockfh.getvalue(), truth) 

1048 

1049 

1050class HTCBackupFilesSinglePathTestCase(unittest.TestCase): 

1051 """Test htc_backup_files_single_path function.""" 

1052 

1053 def testSrcDestSame(self): 

1054 with temporaryDirectory() as tmp_dir: 

1055 with self.assertRaisesRegex( 

1056 RuntimeError, "Destination directory is same as the source directory" 

1057 ): 

1058 lssthtc.htc_backup_files_single_path(tmp_dir, tmp_dir) 

1059 

1060 def testSuccess(self): 

1061 with temporaryDirectory() as tmp_dir: 

1062 test_tmp_dir = pathlib.Path(tmp_dir) 

1063 submit_dir = test_tmp_dir / "the_src_dir" 

1064 copytree(f"{TESTDIR}/data/tiny_success", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1065 backup_dir = test_tmp_dir / "the_dest_dir" 

1066 backup_dir.mkdir() 

1067 lssthtc.htc_backup_files_single_path(submit_dir, backup_dir) 

1068 result_submit = [] 

1069 for root, _, files in os.walk(submit_dir): 

1070 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

1071 self.assertEqual( 

1072 set(result_submit), 

1073 { 

1074 "./tiny_success.dag.dagman.log", 

1075 "./tiny_success.dag.dagman.out", 

1076 "./tiny_success.dag", 

1077 }, 

1078 ) 

1079 result_backup = [] 

1080 for root, _, files in os.walk(backup_dir): 

1081 result_backup.extend([str(os.path.join(os.path.relpath(root, backup_dir), f)) for f in files]) 

1082 self.assertEqual( 

1083 set(result_backup), 

1084 { 

1085 "./tiny_success.info.json", 

1086 "./tiny_success.dag.metrics", 

1087 "./tiny_success.dag.nodes.log", 

1088 "./tiny_success.node_status", 

1089 }, 

1090 ) 

1091 

1092 

1093class HTCBackupFilesTestCase(unittest.TestCase): 

1094 """Test htc_backup_files function.""" 

1095 

1096 def testDirectoryNotFound(self): 

1097 with temporaryDirectory() as tmp_dir: 

1098 test_tmp_dir = pathlib.Path(tmp_dir) 

1099 submit_dir = test_tmp_dir / "submit" 

1100 with self.assertRaises(FileNotFoundError): 

1101 lssthtc.htc_backup_files(submit_dir) 

1102 

1103 def testSuccess(self): 

1104 with temporaryDirectory() as tmp_dir: 

1105 test_tmp_dir = pathlib.Path(tmp_dir) 

1106 submit_dir = test_tmp_dir / "submit" 

1107 copytree(f"{TESTDIR}/data/tiny_success", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1108 lssthtc.htc_backup_files(submit_dir) 

1109 result_submit = [] 

1110 for root, _, files in os.walk(submit_dir): 

1111 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

1112 self.assertEqual( 

1113 set(result_submit), 

1114 { 

1115 "./tiny_success.dag.dagman.log", 

1116 "./tiny_success.dag.dagman.out", 

1117 "./tiny_success.dag", 

1118 "000/tiny_success.info.json", 

1119 "000/tiny_success.dag.metrics", 

1120 "000/tiny_success.dag.nodes.log", 

1121 "000/tiny_success.node_status", 

1122 }, 

1123 ) 

1124 

1125 def testDestNotInSubmitDir(self): 

1126 with temporaryDirectory() as tmp_dir: 

1127 test_tmp_dir = pathlib.Path(tmp_dir) 

1128 submit_dir = test_tmp_dir / "submit" 

1129 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1130 with self.assertLogs("lsst.ctrl.bps.htcondor", level="WARNING") as cm: 

1131 lssthtc.htc_backup_files(submit_dir, test_tmp_dir / "backup") 

1132 self.assertIn("Invalid backup location:", cm.output[-1]) 

1133 lssthtc.htc_backup_files(submit_dir) 

1134 result_submit = [] 

1135 for root, _, files in os.walk(submit_dir): 

1136 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

1137 self.assertEqual( 

1138 set(result_submit), 

1139 { 

1140 "./tiny_problems.dag.dagman.log", 

1141 "./tiny_problems.dag.dagman.out", 

1142 "./tiny_problems.dag", 

1143 "./tiny_problems.dag.rescue001", 

1144 "001/tiny_problems.info.json", 

1145 "001/tiny_problems.dag.metrics", 

1146 "001/tiny_problems.dag.nodes.log", 

1147 "001/tiny_problems.node_status", 

1148 }, 

1149 ) 

1150 

1151 def testDestInSubmitDir(self): 

1152 with temporaryDirectory() as tmp_dir: 

1153 test_tmp_dir = pathlib.Path(tmp_dir) 

1154 submit_dir = test_tmp_dir / "submit" 

1155 backup_dir = submit_dir / "subdir" 

1156 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1157 lssthtc.htc_backup_files(submit_dir, backup_dir) 

1158 result_submit = [] 

1159 for root, _, files in os.walk(submit_dir): 

1160 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

1161 self.assertEqual( 

1162 set(result_submit), 

1163 { 

1164 "./tiny_problems.dag.dagman.log", 

1165 "./tiny_problems.dag.dagman.out", 

1166 "./tiny_problems.dag", 

1167 "./tiny_problems.dag.rescue001", 

1168 "subdir/001/tiny_problems.info.json", 

1169 "subdir/001/tiny_problems.dag.metrics", 

1170 "subdir/001/tiny_problems.dag.nodes.log", 

1171 "subdir/001/tiny_problems.node_status", 

1172 }, 

1173 ) 

1174 

1175 def testRelativeSubdir(self): 

1176 with temporaryDirectory() as tmp_dir: 

1177 test_tmp_dir = pathlib.Path(tmp_dir) 

1178 submit_dir = test_tmp_dir / "submit" 

1179 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1180 lssthtc.htc_backup_files(submit_dir, "reldir") 

1181 result_submit = [] 

1182 for root, _, files in os.walk(submit_dir): 

1183 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

1184 self.assertEqual( 

1185 set(result_submit), 

1186 { 

1187 "./tiny_problems.dag.dagman.log", 

1188 "./tiny_problems.dag.dagman.out", 

1189 "./tiny_problems.dag", 

1190 "./tiny_problems.dag.rescue001", 

1191 "reldir/001/tiny_problems.info.json", 

1192 "reldir/001/tiny_problems.dag.metrics", 

1193 "reldir/001/tiny_problems.dag.nodes.log", 

1194 "reldir/001/tiny_problems.node_status", 

1195 }, 

1196 ) 

1197 

1198 def testSubdags(self): 

1199 with temporaryDirectory() as tmp_dir: 

1200 test_tmp_dir = pathlib.Path(tmp_dir) 

1201 submit_dir = test_tmp_dir / "submit" 

1202 copytree(f"{TESTDIR}/data/group_failed_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1203 lssthtc.htc_backup_files(submit_dir) 

1204 result_submit = [] 

1205 for root, _, files in os.walk(submit_dir): 

1206 result_submit.extend([str(os.path.join(os.path.relpath(root, submit_dir), f)) for f in files]) 

1207 self.assertEqual( 

1208 set(result_submit), 

1209 { 

1210 "./group_failed_1.dag", 

1211 "./group_failed_1.dag.dagman.log", 

1212 "./group_failed_1.dag.dagman.out", 

1213 "./group_failed_1.dag.rescue001", 

1214 "subdags/wms_group_order1_val1a/group_order1_val1a.dag", 

1215 "subdags/wms_group_order1_val1a/group_order1_val1a.dag.dagman.log", 

1216 "subdags/wms_group_order1_val1a/group_order1_val1a.dag.dagman.out", 

1217 "subdags/wms_group_order1_val1a/group_order1_val1a.dag.nodes.log", 

1218 "subdags/wms_group_order1_val1a/group_order1_val1a.node_status", 

1219 "subdags/wms_group_order1_val1a/wms_group_order1_val1a.dag.post.out", 

1220 "subdags/wms_group_order1_val1a/wms_group_order1_val1a.status.txt", 

1221 "subdags/wms_group_order1_val1b/group_order1_val1b.dag", 

1222 "subdags/wms_group_order1_val1b/group_order1_val1b.dag.dagman.log", 

1223 "subdags/wms_group_order1_val1b/group_order1_val1b.dag.dagman.out", 

1224 "subdags/wms_group_order1_val1b/group_order1_val1b.dag.rescue001", 

1225 "subdags/wms_group_order1_val1c/group_order1_val1c.dag", 

1226 "subdags/wms_group_order1_val1c/group_order1_val1c.dag.dagman.log", 

1227 "subdags/wms_group_order1_val1c/group_order1_val1c.dag.dagman.out", 

1228 "subdags/wms_group_order1_val1c/group_order1_val1c.dag.nodes.log", 

1229 "subdags/wms_group_order1_val1c/group_order1_val1c.node_status", 

1230 "subdags/wms_group_order1_val1c/wms_group_order1_val1c.dag.post.out", 

1231 "subdags/wms_group_order1_val1c/wms_group_order1_val1c.status.txt", 

1232 "001/group_failed_1.dag.nodes.log", 

1233 "001/group_failed_1.info.json", 

1234 "001/group_failed_1.node_status", 

1235 "001/subdags/wms_group_order1_val1b/group_order1_val1b.dag.nodes.log", 

1236 "001/subdags/wms_group_order1_val1b/group_order1_val1b.node_status", 

1237 "001/subdags/wms_group_order1_val1b/wms_group_order1_val1b.status.txt", 

1238 "001/subdags/wms_group_order1_val1b/wms_group_order1_val1b.dag.post.out", 

1239 }, 

1240 ) 

1241 

1242 

1243class UpdateRescueFileTestCase(unittest.TestCase): 

1244 """Test _update_rescue_file function.""" 

1245 

1246 def testSuccess(self): 

1247 self.maxDiff = None 

1248 with temporaryDirectory() as tmp_dir: 

1249 test_tmp_dir = pathlib.Path(tmp_dir) 

1250 submit_dir = test_tmp_dir / "submit" 

1251 copytree(f"{TESTDIR}/data/group_failed_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1252 rescue_file = submit_dir / "group_failed_1.dag.rescue001" 

1253 failed_subdags = lssthtc._update_rescue_file(rescue_file) 

1254 self.assertEqual(set(failed_subdags), {"wms_group_order1_val1b"}) 

1255 with open(rescue_file) as fh: 

1256 lines = fh.readlines() 

1257 results = "".join(lines) 

1258 

1259 truth = """# Rescue DAG file, created after running 

1260# the u_testuser_DM-46294_group_fail_20250310T160455Z.dag DAG file 

1261# Created 3/10/2025 16:08:56 UTC 

1262# Rescue DAG version: 2.0.1 (partial) 

1263# 

1264# Total number of Nodes: 26 

1265# Nodes premarked DONE: 21 

1266# Nodes that failed: 2 

1267# wms_group_order1_val1b,finalJob,<ENDLIST> 

1268 

1269DONE pipetaskInit 

1270DONE label1_val1c_val2a 

1271DONE label1_val1b_val2b 

1272DONE label1_val1b_val2a 

1273DONE label1_val1c_val2b 

1274DONE label1_val1a_val2a 

1275DONE label1_val1a_val2b 

1276DONE label3_val1c_val2a 

1277DONE label3_val1b_val2b 

1278DONE label3_val1b_val2a 

1279DONE label3_val1c_val2b 

1280DONE label3_val1a_val2a 

1281DONE label3_val1a_val2b 

1282DONE wms_group_order1_val1a 

1283DONE label5_val1a_val2a 

1284DONE label5_val1a_val2b 

1285DONE wms_group_order1_val1c 

1286DONE label5_val1c_val2a 

1287DONE label5_val1c_val2b 

1288DONE wms_check_status_wms_group_order1_val1a 

1289DONE wms_check_status_wms_group_order1_val1c 

1290""" 

1291 

1292 self.assertEqual(results, truth) 

1293 

1294 

1295class ReadRescueHeadersTestCase(unittest.TestCase): 

1296 """Test _read_rescue_headers function.""" 

1297 

1298 def testTypical(self): 

1299 content = "# Header line 1\n# Header line 2\n\nDONE somenode\n" 

1300 result = lssthtc._read_rescue_headers(io.StringIO(content)) 

1301 self.assertEqual(result, ["# Header line 1", "# Header line 2"]) 

1302 

1303 def testEmptyFile(self): 

1304 result = lssthtc._read_rescue_headers(io.StringIO("")) 

1305 self.assertEqual(result, []) 

1306 

1307 def testOnlyHeaderLines(self): 

1308 content = "# Line 1\n# Line 2\n# Line 3\n" 

1309 result = lssthtc._read_rescue_headers(io.StringIO(content)) 

1310 self.assertEqual(result, ["# Line 1", "# Line 2", "# Line 3"]) 

1311 

1312 def testFirstLineNotComment(self): 

1313 content = "DONE somenode\n# Header\n" 

1314 result = lssthtc._read_rescue_headers(io.StringIO(content)) 

1315 self.assertEqual(result, []) 

1316 

1317 def testWhitespaceStripped(self): 

1318 content = " # Header line 1 \n # Header line 2 \n\n" 

1319 result = lssthtc._read_rescue_headers(io.StringIO(content)) 

1320 self.assertEqual(result, ["# Header line 1", "# Header line 2"]) 

1321 

1322 

1323class WriteRescueHeadersTestCase(unittest.TestCase): 

1324 """Test _write_rescue_headers function.""" 

1325 

1326 def testTypical(self): 

1327 header_lines = ["# Header line 1", "# Header line 2", "# Header line 3"] 

1328 outfh = io.StringIO() 

1329 lssthtc._write_rescue_headers(header_lines, outfh) 

1330 self.assertEqual(outfh.getvalue(), "# Header line 1\n# Header line 2\n# Header line 3\n\n") 

1331 

1332 def testEmptyList(self): 

1333 outfh = io.StringIO() 

1334 lssthtc._write_rescue_headers([], outfh) 

1335 self.assertEqual(outfh.getvalue(), "\n") 

1336 

1337 def testSingleLine(self): 

1338 outfh = io.StringIO() 

1339 lssthtc._write_rescue_headers(["# Only line"], outfh) 

1340 self.assertEqual(outfh.getvalue(), "# Only line\n\n") 

1341 

1342 

1343class UpdateRescueHeadersTestCase(unittest.TestCase): 

1344 """Test _update_rescue_headers function.""" 

1345 

1346 def testWithFailedSubdag(self): 

1347 header_lines = [ 

1348 "# Total number of Nodes: 26", 

1349 "# Nodes premarked DONE: 22", 

1350 "# Nodes that failed: 2", 

1351 "# wms_check_status_wms_group_order1_val1b,finalJob,<ENDLIST>", 

1352 ] 

1353 result = lssthtc._update_rescue_headers(header_lines) 

1354 self.assertEqual(result, ["wms_group_order1_val1b"]) 

1355 self.assertEqual(header_lines[1], "# Nodes premarked DONE: 21") 

1356 self.assertEqual(header_lines[3], "# wms_group_order1_val1b,finalJob,<ENDLIST>") 

1357 

1358 def testNoSubdagFailures(self): 

1359 header_lines = [ 

1360 "# Nodes premarked DONE: 5", 

1361 "# Nodes that failed: 1", 

1362 "# finalJob,<ENDLIST>", 

1363 ] 

1364 result = lssthtc._update_rescue_headers(header_lines) 

1365 self.assertEqual(result, []) 

1366 self.assertEqual(header_lines[0], "# Nodes premarked DONE: 5") 

1367 self.assertEqual(header_lines[2], "# finalJob,<ENDLIST>") 

1368 

1369 def testMultipleFailedSubdags(self): 

1370 header_lines = [ 

1371 "# Nodes premarked DONE: 10", 

1372 "# Nodes that failed: 3", 

1373 "# wms_check_status_subdag_a,wms_check_status_subdag_b,finalJob,<ENDLIST>", 

1374 ] 

1375 result = lssthtc._update_rescue_headers(header_lines) 

1376 self.assertEqual(result, ["subdag_a", "subdag_b"]) 

1377 self.assertEqual(header_lines[0], "# Nodes premarked DONE: 8") 

1378 self.assertEqual(header_lines[2], "# subdag_a,subdag_b,finalJob,<ENDLIST>") 

1379 

1380 def testNoFailedNodesLine(self): 

1381 header_lines = [ 

1382 "# Total number of Nodes: 5", 

1383 "# Nodes premarked DONE: 5", 

1384 ] 

1385 original = list(header_lines) 

1386 result = lssthtc._update_rescue_headers(header_lines) 

1387 self.assertEqual(result, []) 

1388 self.assertEqual(header_lines, original) 

1389 

1390 def testEmptyHeader(self): 

1391 result = lssthtc._update_rescue_headers([]) 

1392 self.assertEqual(result, []) 

1393 

1394 

1395class ReadDagStatusTestCase(unittest.TestCase): 

1396 """Test read_dag_status function and read_single_dag_status.""" 

1397 

1398 def testFileMissing(self): 

1399 with temporaryDirectory() as tmp_dir: 

1400 with self.assertRaisesRegex(FileNotFoundError, "DAGMan node status not found"): 

1401 _ = lssthtc.read_dag_status(tmp_dir) 

1402 

1403 def testRegular(self): 

1404 with temporaryDirectory() as tmp_dir: 

1405 submit_dir = os.path.join(tmp_dir, "tiny_problems") 

1406 copytree(f"{TESTDIR}/data/tiny_problems", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1407 results = lssthtc.read_dag_status(submit_dir) 

1408 truth = { 

1409 "JobProcsHeld": 0, 

1410 "NodesPost": 0, 

1411 "JobProcsIdle": 0, 

1412 "NodesTotal": 6, 

1413 "NodesFailed": 2, 

1414 "NodesDone": 3, 

1415 "NodesQueued": 0, 

1416 "NodesPre": 0, 

1417 "NodesFutile": 1, 

1418 "NodesUnready": 0, 

1419 } 

1420 self.assertEqual(results, results | truth) 

1421 

1422 def testSubdags(self): 

1423 """Making sure it gets data from subdag dirs and doesn't 

1424 fail if some subdags haven't started running yet. 

1425 """ 

1426 self.maxDiff = None 

1427 with temporaryDirectory() as tmp_dir: 

1428 submit_dir = os.path.join(tmp_dir, "submit") 

1429 copytree(f"{TESTDIR}/data/group_running_1", submit_dir, ignore=ignore_patterns("*~", ".???*")) 

1430 results = lssthtc.read_dag_status(submit_dir) 

1431 truth = { 

1432 "JobProcsHeld": 0, 

1433 "NodesPost": 0, 

1434 "JobProcsIdle": 0, 

1435 "NodesTotal": 34, 

1436 "NodesFailed": 0, 

1437 "NodesDone": 17, 

1438 "NodesQueued": 3, 

1439 "NodesPre": 0, 

1440 "NodesFutile": 0, 

1441 "NodesUnready": 14, 

1442 } 

1443 self.assertEqual(results, results | truth) 

1444 

1445 

1446class ReadDagInfoTestCase(unittest.TestCase): 

1447 """Test read_dag_info function.""" 

1448 

1449 def testFileMissing(self): 

1450 with temporaryDirectory() as tmp_dir: 

1451 with self.assertRaisesRegex(FileNotFoundError, "File with DAGMan job information not found in "): 

1452 _ = lssthtc.read_dag_info(tmp_dir) 

1453 

1454 def testSuccess(self): 

1455 with temporaryDirectory() as tmp_dir: 

1456 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.info.json", tmp_dir) 

1457 filename, results = lssthtc.read_dag_info(tmp_dir) 

1458 self.assertIn("info.json", str(filename)) 

1459 self.assertTrue(pathlib.Path(filename).is_file()) 

1460 

1461 truth = { 

1462 "test02": { 

1463 "9208.0": { 

1464 "ClusterId": 9208, 

1465 "GlobalJobId": "test02#9208.0#1739465078", 

1466 "bps_wms_service": "lsst.ctrl.bps.htcondor.htcondor_service.HTCondorService", 

1467 "bps_project": "dev", 

1468 "bps_payload": "tiny", 

1469 "bps_operator": "testuser", 

1470 "bps_wms_workflow": "lsst.ctrl.bps.htcondor.htcondor_service.HTCondorWorkflow", 

1471 "bps_provisioning_job": "provisioningJob", 

1472 "bps_run_quanta": "label1:1;label2:1", 

1473 "bps_campaign": "quick", 

1474 "bps_runsite": "testpool", 

1475 "bps_job_summary": "pipetaskInit:1;label1:1;label2:1;finalJob:1", 

1476 "bps_run": "u_testuser_tiny_20250213T164427Z", 

1477 "bps_isjob": "True", 

1478 } 

1479 } 

1480 } 

1481 

1482 self.assertEqual(results, truth) 

1483 

1484 def testPermissionError(self): 

1485 with temporaryDirectory() as tmp_dir: 

1486 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.info.json", tmp_dir) 

1487 with patch("lsst.ctrl.bps.htcondor.lssthtc.open") as mocked_open: 

1488 mocked_open.side_effect = PermissionError 

1489 with self.assertLogs("lsst.ctrl.bps.htcondor", level="DEBUG") as cm: 

1490 _, results = lssthtc.read_dag_info(tmp_dir) 

1491 self.assertIn("Retrieving DAGMan job information failed:", cm.output[-1]) 

1492 self.assertEqual({}, results) 

1493 

1494 

1495class HtcWriteCondorFileTestCase(unittest.TestCase): 

1496 """Test htc_write_condor_file function.""" 

1497 

1498 def testSuccess(self): 

1499 with temporaryDirectory() as tmp_dir: 

1500 job_name = "job1" 

1501 filename = pathlib.Path(tmp_dir) / f"label1/{job_name}.sub" 

1502 job = { 

1503 "executable": "$(CTRL_MPEXEC_DIR)/bin/pipetask", 

1504 "arguments": "-a -b 2 -c", 

1505 "request_memory": "2000", 

1506 "environment": "one=1 two=\"2\" three='spacey 'quoted' value'", 

1507 "log": f"{job_name}.log", 

1508 } 

1509 job_attrs = { 

1510 "bps_job_name": job_name, 

1511 "bps_job_label": "label1", 

1512 "bps_job_quanta": "task1:8;task2:8", 

1513 } 

1514 expected = [ 

1515 "executable=$(CTRL_MPEXEC_DIR)/bin/pipetask\n", 

1516 'arguments="-a -b 2 -c"\n', 

1517 "request_memory=2000\n", 

1518 "environment=\"one=1 two=\"2\" three='spacey 'quoted' value'\"\n", 

1519 f"output={job_name}.$(Cluster).out\n", 

1520 f"error={job_name}.$(Cluster).out\n", 

1521 f"log={job_name}.log\n", 

1522 f'+bps_job_name = "{job_name}"\n', 

1523 '+bps_job_label = "label1"\n', 

1524 '+bps_job_quanta = "task1:8;task2:8"\n', 

1525 "queue\n", 

1526 ] 

1527 

1528 lssthtc.htc_write_condor_file(filename, job_name, job, job_attrs) 

1529 with open(filename, encoding="utf-8") as f: 

1530 actual = f.readlines() 

1531 

1532 self.assertEqual(set(actual), set(expected)) 

1533 self.assertTrue(filename.exists()) 

1534 # Try to make Submit object from file to find any syntax issues 

1535 _ = lssthtc.htc_create_submit_from_file(filename) 

1536 

1537 

1538class HtcCreateSubmitFromDagTestCase(unittest.TestCase): 

1539 """Test htc_create_submit_from_dag function.""" 

1540 

1541 @classmethod 

1542 def setUpClass(cls): 

1543 cls.bindir = None 

1544 # htcondor.Submit.from_dag requires condor_dagman executable in path. 

1545 if not which("condor_dagman"): # pragma: no cover 

1546 cls.bindir = tempfile.TemporaryDirectory() 

1547 fake_dagman_exec = pathlib.Path(cls.bindir.name) / "condor_dagman" 

1548 with open(fake_dagman_exec, "w") as fh: 

1549 print("#!/bin/bash", file=fh) 

1550 print("echo fake_condor_dagman $@", file=fh) 

1551 print("exit 0", file=fh) 

1552 fake_dagman_exec.chmod(fake_dagman_exec.stat().st_mode | stat.S_IEXEC) 

1553 os.environ["PATH"] = f"{os.environ['PATH']}:{cls.bindir.name}" 

1554 

1555 @classmethod 

1556 def tearDownClass(cls): 

1557 if cls.bindir: 1557 ↛ 1558line 1557 didn't jump to line 1558 because the condition on line 1557 was never true

1558 cls.bindir.cleanup() 

1559 

1560 @patch.dict(os.environ, {"_CONDOR_DAGMAN_MAX_JOBS_IDLE": "42"}) 

1561 def testMaxIdleEnvVar(self): 

1562 with temporaryDirectory() as tmp_dir: 

1563 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1564 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1565 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}) 

1566 self.assertIn("-MaxIdle 42", submit["arguments"]) 

1567 

1568 @patch.dict(os.environ, {"_CONDOR_DAGMAN_MAX_JOBS_IDLE": "42"}) 

1569 def testMaxIdleInDAGManConfig(self): 

1570 with temporaryDirectory() as tmp_dir: 

1571 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1572 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1573 config_filename = pathlib.Path(tmp_dir) / "dagman.conf" 

1574 with open(config_filename, "w") as fh: 

1575 print("DAGMAN_MAX_JOBS_IDLE = 300", file=fh) 

1576 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}, config_filename) 

1577 self.assertIn("-MaxIdle 300", submit["arguments"]) 

1578 

1579 @patch.dict(os.environ, {"_CONDOR_DAGMAN_MAX_JOBS_IDLE": "42"}) 

1580 def testMaxIdleNotInDAGManConfig(self): 

1581 with temporaryDirectory() as tmp_dir: 

1582 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1583 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1584 config_filename = pathlib.Path(tmp_dir) / "dagman.conf" 

1585 with open(config_filename, "w") as fh: 

1586 print("DAGMAN_MAX_JOBS_SUBMITTED = 300", file=fh) 

1587 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}, config_filename) 

1588 self.assertIn("-MaxIdle 42", submit["arguments"]) 

1589 

1590 @patch.dict(os.environ, {}) 

1591 def testMaxIdleGiven(self): 

1592 with temporaryDirectory() as tmp_dir: 

1593 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1594 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1595 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {"MaxIdle": 37}) 

1596 self.assertIn("-MaxIdle 37", submit["arguments"]) 

1597 

1598 @patch.dict(os.environ, {}) 

1599 def testMaxJobsIdleParam(self): 

1600 def _fake_params_contains(key): 

1601 if key == "DAGMAN_MAX_JOBS_IDLE": 

1602 return True 

1603 return False # pragma: no cover 

1604 

1605 def _fake_params_get(key): 

1606 if key == "DAGMAN_MAX_JOBS_IDLE": 

1607 return 16 

1608 return "FAKE_VAL" # pragma: no cover 

1609 

1610 with temporaryDirectory() as tmp_dir: 

1611 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1612 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1613 with patch("lsst.ctrl.bps.htcondor.lssthtc.param") as mock_param: 

1614 mock_param.__contains__.side_effect = _fake_params_contains 

1615 mock_param.__getitem__.side_effect = _fake_params_get 

1616 submit = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}, None) 

1617 self.assertIn("-MaxIdle 16", submit["arguments"]) 

1618 

1619 @patch.dict(os.environ, {}) 

1620 def testNoMaxJobsIdle(self): 

1621 """Note: Since the produced arguments differ depending on 

1622 HTCondor version when no MaxIdle passed to from_dag, not 

1623 checking arguments string here. Instead just making sure 

1624 lssthtc code doesn't pass MaxIdle value to from_dag. 

1625 """ 

1626 with temporaryDirectory() as tmp_dir: 

1627 copy2(f"{TESTDIR}/data/tiny_success/tiny_success.dag", tmp_dir) 

1628 dag_filename = pathlib.Path(tmp_dir) / "tiny_success.dag" 

1629 with patch("lsst.ctrl.bps.htcondor.lssthtc.Submit.from_dag") as submit_mock: 

1630 with patch("lsst.ctrl.bps.htcondor.lssthtc.param") as mock_param: 

1631 mock_param.__contains__.return_value = False 

1632 _ = lssthtc.htc_create_submit_from_dag(str(dag_filename), {}) 

1633 submit_mock.assert_called_once_with(str(dag_filename), {}) 

1634 

1635 

1636class HtcDagTestCase(unittest.TestCase): 

1637 """Test for HTCDag class.""" 

1638 

1639 def setUp(self): 

1640 job = lssthtc.HTCJob(name="test_job") 

1641 job.add_job_cmds( 

1642 { 

1643 "executable": "/usr/bin/echo", 

1644 "arguments": "foo", 

1645 "output": "test_job.$(Cluster).out", 

1646 "error": "test_job.$(Cluster).out", 

1647 "log": "test_job.$(Cluster).log", 

1648 } 

1649 ) 

1650 job.subfile = f"{job.name}.sub" 

1651 

1652 self.dag = lssthtc.HTCDag(name="test_workflow") 

1653 self.dag.add_job(job) 

1654 

1655 self.subfile_expected = [ 

1656 "executable=/usr/bin/echo\n", 

1657 'arguments="foo"\n', 

1658 "output=test_job.$(Cluster).out\n", 

1659 "error=test_job.$(Cluster).out\n", 

1660 "log=test_job.$(Cluster).log\n", 

1661 "queue\n", 

1662 ] 

1663 

1664 def tearDown(self): 

1665 pass 

1666 

1667 def testWriteWithDagConfig(self): 

1668 with temporaryDirectory() as tmp_dir: 

1669 config = BpsConfig(Config(htcondor_config.HTC_DEFAULTS_URI)) 

1670 job = self.dag.nodes["test_job"]["data"] 

1671 wms_config_filename = "dagman.conf" 

1672 wms_configurator = dagman_configurator.DagmanConfigurator(config) 

1673 wms_configurator.prepare(wms_config_filename, prefix=tmp_dir) 

1674 wms_configurator.configure(self.dag) 

1675 dagfile_expected = [ 

1676 f"CONFIG {wms_config_filename}\n", 

1677 f'JOB {job.name} "{job.subfile}"\n', 

1678 f"NODE_STATUS_FILE {self.dag.name}.node_status\n", 

1679 f'SET_JOB_ATTR bps_wms_config_path= "{wms_config_filename}"\n', 

1680 ] 

1681 

1682 self.dag.write(tmp_dir, "", "") 

1683 

1684 self.assertIn("submit_path", self.dag.graph) 

1685 self.assertEqual(self.dag.graph["submit_path"], tmp_dir) 

1686 self.assertIn("dag_filename", self.dag.graph) 

1687 self.assertEqual(self.dag.graph["dag_filename"], f"{self.dag.graph['name']}.dag") 

1688 with open(os.path.join(tmp_dir, self.dag.graph["dag_filename"]), encoding="utf-8") as f: 

1689 dagfile_actual = f.readlines() 

1690 self.assertEqual(dagfile_actual, dagfile_expected) 

1691 with open(os.path.join(tmp_dir, job.subfile), encoding="utf-8") as f: 

1692 subfile_actual = f.readlines() 

1693 self.assertEqual(subfile_actual, self.subfile_expected) 

1694 

1695 def testWriteWithoutDagConfig(self): 

1696 with temporaryDirectory() as tmp_dir: 

1697 job = self.dag.nodes["test_job"]["data"] 

1698 dagfile_expected = [ 

1699 f'JOB {job.name} "{job.subfile}"\n', 

1700 f"NODE_STATUS_FILE {self.dag.name}.node_status\n", 

1701 ] 

1702 

1703 self.dag.write(tmp_dir, "", "") 

1704 

1705 self.assertIn("submit_path", self.dag.graph) 

1706 self.assertEqual(self.dag.graph["submit_path"], tmp_dir) 

1707 self.assertIn("dag_filename", self.dag.graph) 

1708 self.assertEqual(self.dag.graph["dag_filename"], f"{self.dag.graph['name']}.dag") 

1709 with open(os.path.join(tmp_dir, self.dag.graph["dag_filename"]), encoding="utf-8") as f: 

1710 dagfile_actual = f.readlines() 

1711 self.assertEqual(dagfile_actual, dagfile_expected) 

1712 with open(os.path.join(tmp_dir, job.subfile), encoding="utf-8") as f: 

1713 subfile_actual = f.readlines() 

1714 self.assertEqual(subfile_actual, self.subfile_expected) 

1715 

1716 def testWriteLazySubdag(self): 

1717 self.maxDiff = None 

1718 dag, truth_files = make_lazy_dag("test1", True) 

1719 dag.graph["write_dot"] = True 

1720 with temporaryDirectory() as tmp_dir: 

1721 dag.write(tmp_dir, "", "") 

1722 with open(os.path.join(tmp_dir, dag.graph["dag_filename"]), encoding="utf-8") as f: 

1723 dagfile_actual = f.readlines() 

1724 self.assertIn("DOT test1.dot\n", dagfile_actual) 

1725 

1726 all_files = [] 

1727 for root, _, files in pathlib.Path(tmp_dir).walk(): 

1728 relroot = pathlib.Path(root).relative_to(tmp_dir) 

1729 all_files.extend([str(relroot / f) for f in files]) 

1730 self.assertEqual(sorted(all_files), sorted(truth_files)) 

1731 

1732 @staticmethod 

1733 def _make_simple_job(name): 

1734 job = lssthtc.HTCJob(name=name) 

1735 job.add_job_cmds({"executable": "/usr/bin/echo", "arguments": name}) 

1736 job.subfile = f"{name}.sub" 

1737 return job 

1738 

1739 def testWriteEdgesAndSpecialJobs(self): 

1740 self.maxDiff = None 

1741 dag = lssthtc.HTCDag(name="test_special") 

1742 dag.add_attribs({"bps_run": "myrun"}) 

1743 dag.add_job(self._make_simple_job("jobA")) 

1744 dag.add_job(self._make_simple_job("jobB")) 

1745 dag.add_job_relationships(["jobA"], ["jobB"]) 

1746 dag.add_final_job(self._make_simple_job("finalJob")) 

1747 dag.add_service_job(self._make_simple_job("svc")) 

1748 

1749 dagfile_expected = [ 

1750 'JOB jobA "jobA.sub"\n', 

1751 'JOB jobB "jobB.sub"\n', 

1752 "PARENT jobA CHILD jobB\n", 

1753 f"NODE_STATUS_FILE {dag.name}.node_status\n", 

1754 'SET_JOB_ATTR bps_run= "myrun"\n', 

1755 'FINAL finalJob "finalJob.sub"\n', 

1756 'SERVICE svc "svc.sub"\n', 

1757 ] 

1758 

1759 with temporaryDirectory() as tmp_dir: 

1760 dag.write(tmp_dir, "", "") 

1761 with open(os.path.join(tmp_dir, dag.graph["dag_filename"]), encoding="utf-8") as f: 

1762 self.assertEqual(f.readlines(), dagfile_expected) 

1763 # Submit files are written for regular and special jobs. 

1764 for subfile in ("jobA.sub", "jobB.sub", "finalJob.sub", "svc.sub"): 

1765 self.assertTrue(os.path.exists(os.path.join(tmp_dir, subfile))) 

1766 

1767 def testWriteDagSubdir(self): 

1768 # A non-empty dag_subdir places the .dag file (and DIR clause) under 

1769 # that subdirectory, creating it as needed. 

1770 dag = lssthtc.HTCDag(name="test_subdir") 

1771 dag.add_job(self._make_simple_job("jobA")) 

1772 

1773 with temporaryDirectory() as tmp_dir: 

1774 dag.write(tmp_dir, "", "daglevel") 

1775 self.assertEqual(dag.graph["dag_filename"], "daglevel/test_subdir.dag") 

1776 self.assertTrue(os.path.exists(os.path.join(tmp_dir, "daglevel", "test_subdir.dag"))) 

1777 

1778 def testWriteMissingJobData(self): 

1779 # A node without the "data" key should raise KeyError. 

1780 dag = lssthtc.HTCDag(name="test_baddata") 

1781 dag.add_node("orphan") # no data=... provided 

1782 with temporaryDirectory() as tmp_dir: 

1783 with self.assertRaises(KeyError): 

1784 dag.write(tmp_dir, "", "") 

1785 

1786 

1787class WriteDagInfoTestCase(unittest.TestCase): 

1788 """Test for write_dag_info function.""" 

1789 

1790 def setUp(self): 

1791 self.run = "u_testuser_DM-53494_20260220T001651Z" 

1792 self.data = { 

1793 "mycomputer": { 

1794 "24390.0": { 

1795 "ClusterId": 24390, 

1796 "GlobalJobId": "mycomputer#24390.0#1771546612", 

1797 "bps_run": self.run, 

1798 "bps_isjob": "True", 

1799 "bps_payload": "DM-53494", 

1800 "bps_project": "dev", 

1801 "bps_runsite": "site1", 

1802 "bps_campaign": "ci_rc2", 

1803 "bps_operator": "testuser", 

1804 "bps_run_quanta": "", 

1805 "bps_job_summary": "buildQuantumGraph:1;preparePayloadWorkflow:1;dummyJob:1", 

1806 "bps_wms_service": "lsst.ctrl.bps.htcondor.htcondor_service.HTCondorService", 

1807 "bps_wms_workflow": "lsst.ctrl.bps.htcondor.htcondor_workflow.HTCondorWorkflow", 

1808 "bps_wms_config_path": "dagman.conf", 

1809 } 

1810 } 

1811 } 

1812 

1813 def testWrite(self): 

1814 with temporaryDirectory() as tmp_dir: 

1815 with chdir(tmp_dir): 

1816 path = pathlib.Path(tmp_dir) / "test.info.json" 

1817 filename = lssthtc.write_dag_info(path, self.data) 

1818 self.assertTrue(path.is_file(), f"File not found at {path}") 

1819 self.assertEqual(filename, path) 

1820 

1821 read_filename, read_data = lssthtc.read_dag_info(tmp_dir) 

1822 self.assertEqual(read_filename, path) 

1823 self.assertEqual(read_data, self.data) 

1824 

1825 

1826if __name__ == "__main__": 

1827 unittest.main()