Coverage for python/lsst/ctrl/bps/panda/panda_service.py: 9%

230 statements  

« prev     ^ index     » next       coverage.py v7.10.6, created at 2025-10-09 03:49 -0700

1# This file is part of ctrl_bps_panda. 

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"""Interface between generic workflow to PanDA/iDDS workflow system.""" 

28 

29__all__ = ["PanDAService", "PandaBpsWmsWorkflow"] 

30 

31 

32import json 

33import logging 

34import os 

35import pickle 

36import re 

37 

38from idds.workflowv2.workflow import Workflow as IDDS_client_workflow 

39 

40from lsst.ctrl.bps import ( 

41 DEFAULT_MEM_FMT, 

42 DEFAULT_MEM_UNIT, 

43 BaseWmsService, 

44 BaseWmsWorkflow, 

45 WmsRunReport, 

46 WmsStates, 

47) 

48from lsst.ctrl.bps.panda.constants import PANDA_DEFAULT_MAX_COPY_WORKERS 

49from lsst.ctrl.bps.panda.utils import ( 

50 add_final_idds_work, 

51 add_idds_work, 

52 copy_files_for_distribution, 

53 create_idds_build_workflow, 

54 get_idds_client, 

55 get_idds_result, 

56) 

57from lsst.resources import ResourcePath 

58from lsst.utils.timer import time_this 

59 

60_LOG = logging.getLogger(__name__) 

61 

62 

63class PanDAService(BaseWmsService): 

64 """PanDA version of WMS service.""" 

65 

66 def prepare(self, config, generic_workflow, out_prefix=None): 

67 # Docstring inherited from BaseWmsService.prepare. 

68 _LOG.debug("out_prefix = '%s'", out_prefix) 

69 

70 _LOG.info("Starting PanDA prepare stage (creating specific implementation of workflow)") 

71 

72 with time_this( 

73 log=_LOG, 

74 level=logging.INFO, 

75 prefix=None, 

76 msg="PanDA prepare stage completed", 

77 mem_usage=True, 

78 mem_unit=DEFAULT_MEM_UNIT, 

79 mem_fmt=DEFAULT_MEM_FMT, 

80 ): 

81 workflow = PandaBpsWmsWorkflow.from_generic_workflow( 

82 config, generic_workflow, out_prefix, f"{self.__class__.__module__}.{self.__class__.__name__}" 

83 ) 

84 workflow.write(out_prefix) 

85 return workflow 

86 

87 def submit(self, workflow, **kwargs): 

88 config = kwargs["config"] if "config" in kwargs else None 

89 remote_build = kwargs["remote_build"] if "remote_build" in kwargs else None 

90 

91 if config and remote_build: 

92 _LOG.info("remote build") 

93 

94 idds_build_workflow = create_idds_build_workflow(**kwargs) 

95 idds_client = get_idds_client(self.config) 

96 ret = idds_client.submit_build(idds_build_workflow, username=None, use_dataset_name=False) 

97 _LOG.debug("iDDS client manager submit returned = %s", ret) 

98 

99 # Check submission success 

100 status, result, error = get_idds_result(ret) 

101 if status: 

102 request_id = int(result) 

103 else: 

104 raise RuntimeError(f"Error submitting to PanDA service: {error}") 

105 

106 _LOG.info("Submitted into iDDs with request id=%s", request_id) 

107 idds_build_workflow.run_id = request_id 

108 return idds_build_workflow 

109 

110 else: 

111 _, max_copy_workers = self.config.search( 

112 "maxCopyWorkers", opt={"default": PANDA_DEFAULT_MAX_COPY_WORKERS} 

113 ) 

114 file_distribution_uri = self.config["fileDistributionEndPoint"] 

115 lsst_temp = "LSST_RUN_TEMP_SPACE" 

116 if lsst_temp in file_distribution_uri and lsst_temp not in os.environ: 

117 file_distribution_uri = self.config["fileDistributionEndPointDefault"] 

118 protocol_pattern = re.compile(r"^[a-zA-Z][a-zA-Z\d+\-.]*://") 

119 if not protocol_pattern.match(file_distribution_uri): 

120 file_distribution_uri = "file://" + file_distribution_uri 

121 

122 submit_cmd = workflow.run_attrs.get("bps_iscustom", False) 

123 if not submit_cmd: 

124 copy_files_for_distribution( 

125 workflow.files_to_pre_stage, 

126 ResourcePath(file_distribution_uri, forceDirectory=True), 

127 max_copy_workers, 

128 ) 

129 

130 idds_client = get_idds_client(self.config) 

131 ret = idds_client.submit(workflow.idds_client_workflow, username=None, use_dataset_name=False) 

132 _LOG.debug("iDDS client manager submit returned = %s", ret) 

133 

134 # Check submission success 

135 status, result, error = get_idds_result(ret) 

136 if status: 

137 request_id = int(result) 

138 else: 

139 raise RuntimeError(f"Error submitting to PanDA service: {error}") 

140 

141 _LOG.info("Submitted into iDDs with request id=%s", request_id) 

142 workflow.run_id = request_id 

143 

144 def restart(self, wms_workflow_id): 

145 # Docstring inherited from BaseWmsService.restart. 

146 idds_client = get_idds_client(self.config) 

147 ret = idds_client.retry(request_id=wms_workflow_id) 

148 _LOG.debug("Restart PanDA workflow returned = %s", ret) 

149 

150 status, result, error = get_idds_result(ret) 

151 if status: 

152 _LOG.info("Restarting PanDA workflow %s", result) 

153 return wms_workflow_id, None, json.dumps(result) 

154 

155 return None, None, f"Error retry PanDA workflow: {error}" 

156 

157 def report( 

158 self, 

159 wms_workflow_id=None, 

160 user=None, 

161 hist=0, 

162 pass_thru=None, 

163 is_global=False, 

164 return_exit_codes=False, 

165 ): 

166 # Docstring inherited from BaseWmsService.report. 

167 message = "" 

168 run_reports = [] 

169 

170 if not wms_workflow_id: 

171 message = "Run summary not implemented yet, use 'bps report --id <workflow_id>' instead" 

172 return run_reports, message 

173 

174 idds_client = get_idds_client(self.config) 

175 ret = idds_client.get_requests(request_id=wms_workflow_id, with_detail=True) 

176 _LOG.debug("PanDA get workflow status returned = %s", str(ret)) 

177 

178 request_status = ret[0] 

179 if request_status != 0: 

180 raise RuntimeError(f"Error to get workflow status: {ret} for id: {wms_workflow_id}") 

181 

182 tasks = ret[1][1] 

183 if not tasks: 

184 message = f"No records found for workflow id '{wms_workflow_id}'. Hint: double check the id" 

185 else: 

186 head = tasks[0] 

187 wms_report = WmsRunReport( 

188 wms_id=str(head["request_id"]), 

189 operator=head["username"], 

190 project="", 

191 campaign="", 

192 payload="", 

193 run=head["name"], 

194 state=WmsStates.UNKNOWN, 

195 total_number_jobs=0, 

196 job_state_counts=dict.fromkeys(WmsStates, 0), 

197 job_summary={}, 

198 run_summary="", 

199 exit_code_summary=[], 

200 ) 

201 

202 # The status of a task is taken from the first item of state_map. 

203 # The workflow is in status WmsStates.FAILED when: 

204 # All tasks have failed. 

205 # SubFinished tasks has jobs in 

206 # output_processed_files: Finished 

207 # output_failed_files: Failed 

208 # output_missing_files: Missing 

209 state_map = { 

210 "Finished": [WmsStates.SUCCEEDED], 

211 "SubFinished": [ 

212 WmsStates.SUCCEEDED, 

213 WmsStates.FAILED, 

214 WmsStates.PRUNED, 

215 ], 

216 "Transforming": [ 

217 WmsStates.RUNNING, 

218 WmsStates.SUCCEEDED, 

219 WmsStates.FAILED, 

220 WmsStates.UNREADY, 

221 WmsStates.PRUNED, 

222 ], 

223 "Failed": [WmsStates.FAILED, WmsStates.PRUNED], 

224 } 

225 

226 file_map = { 

227 WmsStates.SUCCEEDED: "output_processed_files", 

228 WmsStates.RUNNING: "output_processing_files", 

229 WmsStates.FAILED: "output_failed_files", 

230 WmsStates.UNREADY: "input_new_files", 

231 WmsStates.PRUNED: "output_missing_files", 

232 } 

233 

234 workflow_status = head["status"]["attributes"]["_name_"] 

235 if workflow_status in ["Finished", "SubFinished"]: 

236 wms_report.state = WmsStates.SUCCEEDED 

237 elif workflow_status in ["Failed", "Expired"]: 

238 wms_report.state = WmsStates.FAILED 

239 elif workflow_status in ["Cancelled"]: 

240 wms_report.state = WmsStates.DELETED 

241 elif workflow_status in ["Suspended"]: 

242 wms_report.state = WmsStates.HELD 

243 else: 

244 wms_report.state = WmsStates.RUNNING 

245 

246 try: 

247 tasks.sort(key=lambda x: x["transform_workload_id"]) 

248 except Exception: 

249 tasks.sort(key=lambda x: x["transform_id"]) 

250 

251 exit_codes_all = {} 

252 # Loop over all tasks data returned by idds_client 

253 for task in tasks: 

254 if task["transform_id"] is None: 

255 # Not created task (It happens because of an outer join 

256 # between requests table and transforms table). 

257 continue 

258 

259 exit_codes = [] 

260 totaljobs = task["output_total_files"] 

261 wms_report.total_number_jobs += totaljobs 

262 tasklabel = task["transform_name"] 

263 tasklabel = re.sub(wms_report.run + "_", "", tasklabel) 

264 status = task["transform_status"]["attributes"]["_name_"] 

265 taskstatus = {} 

266 # if the state is failed, gather exit code information 

267 if status in ["SubFinished", "Failed"]: 

268 transform_workload_id = task["transform_workload_id"] 

269 if not (task["transform_name"] and task["transform_name"].startswith("build_task")): 

270 new_ret = idds_client.get_contents_output_ext( 

271 request_id=wms_workflow_id, workload_id=transform_workload_id 

272 ) 

273 _LOG.debug( 

274 "PanDA get task %s detail returned = %s", transform_workload_id, str(new_ret) 

275 ) 

276 

277 request_status = new_ret[0] 

278 if request_status != 0: 

279 raise RuntimeError( 

280 f"Error to get workflow status: {new_ret} for id: {wms_workflow_id}" 

281 ) 

282 # task_info is a dictionary of len 1 that contains 

283 # a list of dicts containing panda job info 

284 task_info = new_ret[1][1] 

285 

286 if len(task_info) == 1: 

287 wmskey = list(task_info.keys())[0] 

288 wmsjobs = task_info[wmskey] 

289 else: 

290 err_msg = "Unexpected job return from PanDA: " 

291 err_msg += f"{task_info} for id: {transform_workload_id}" 

292 raise RuntimeError(err_msg) 

293 exit_codes = [ 

294 wmsjob["trans_exit_code"] 

295 for wmsjob in wmsjobs 

296 if wmsjob["trans_exit_code"] is not None and int(wmsjob["trans_exit_code"]) != 0 

297 ] 

298 exit_codes_all[tasklabel] = exit_codes 

299 # Fill number of jobs in all WmsStates 

300 for state in WmsStates: 

301 njobs = 0 

302 # Each WmsState have many iDDS status mapped to it. 

303 if status in state_map: 

304 for mappedstate in state_map[status]: 

305 if state in file_map and mappedstate == state: 

306 if task[file_map[mappedstate]] is not None: 

307 njobs = task[file_map[mappedstate]] 

308 if state == WmsStates.RUNNING: 

309 njobs += task["output_new_files"] - task["input_new_files"] 

310 break 

311 wms_report.job_state_counts[state] += njobs 

312 taskstatus[state] = njobs 

313 wms_report.job_summary[tasklabel] = taskstatus 

314 

315 # To fill the EXPECTED column 

316 if wms_report.run_summary: 

317 wms_report.run_summary += ";" 

318 wms_report.run_summary += f"{tasklabel}:{totaljobs}" 

319 

320 wms_report.exit_code_summary = exit_codes_all 

321 run_reports.append(wms_report) 

322 

323 return run_reports, message 

324 

325 def list_submitted_jobs(self, wms_id=None, user=None, require_bps=True, pass_thru=None, is_global=False): 

326 # Docstring inherited from BaseWmsService.list_submitted_jobs. 

327 if wms_id is None and user is not None: 

328 raise RuntimeError( 

329 "Error to get workflow status report: wms_id is required" 

330 " and filtering workflows with 'user' is not supported." 

331 ) 

332 

333 idds_client = get_idds_client(self.config) 

334 ret = idds_client.get_requests(request_id=wms_id) 

335 _LOG.debug("PanDA get workflows returned = %s", ret) 

336 

337 status, result, error = get_idds_result(ret) 

338 if status: 

339 req_ids = [req["request_id"] for req in result] 

340 return req_ids 

341 

342 raise RuntimeError(f"Error list PanDA workflow requests: {error}") 

343 

344 def cancel(self, wms_id, pass_thru=None): 

345 # Docstring inherited from BaseWmsService.cancel. 

346 idds_client = get_idds_client(self.config) 

347 ret = idds_client.abort(request_id=wms_id) 

348 _LOG.debug("Abort PanDA workflow returned = %s", ret) 

349 

350 status, result, error = get_idds_result(ret) 

351 if status: 

352 _LOG.info("Aborting PanDA workflow %s", result) 

353 return True, json.dumps(result) 

354 

355 return False, f"Error abort PanDA workflow: {error}" 

356 

357 def ping(self, pass_thru=None): 

358 # Docstring inherited from BaseWmsService.ping. 

359 idds_client = get_idds_client(self.config) 

360 ret = idds_client.ping() 

361 _LOG.debug("Ping PanDA service returned = %s", ret) 

362 

363 status, result, error = get_idds_result(ret) 

364 if status: 

365 if "Status" in result and result["Status"] == "OK": 

366 return 0, None 

367 

368 return -1, f"Error ping PanDA service: {result}" 

369 

370 return -1, f"Error ping PanDA service: {error}" 

371 

372 def run_submission_checks(self): 

373 # Docstring inherited from BaseWmsService.run_submission_checks. 

374 for key in ["PANDA_URL"]: 

375 if key not in os.environ: 

376 raise OSError(f"Missing environment variable {key}") 

377 

378 status, message = self.ping() 

379 if status != 0: 

380 raise RuntimeError(message) 

381 

382 def get_status( 

383 self, 

384 wms_workflow_id=None, 

385 hist=0, 

386 is_global=False, 

387 ): 

388 # Docstring inherited from BaseWmsService.get_status. 

389 

390 idds_client = get_idds_client(self.config) 

391 ret = idds_client.get_requests(request_id=wms_workflow_id, with_detail=False) 

392 _LOG.debug("PanDA get workflow status returned = %s", str(ret)) 

393 

394 request_status = ret[0] 

395 if request_status != 0: 

396 state = WmsStates.UNKNOWN 

397 message = f"Error getting workflow status for id {wms_workflow_id}: ret = {ret}" 

398 else: 

399 tasks = ret[1][1] 

400 if not tasks: 

401 state = WmsStates.UNKNOWN 

402 message = f"No records found for workflow id '{wms_workflow_id}'. Hint: double check the id" 

403 elif not isinstance(tasks[0], dict): 

404 state = WmsStates.UNKNOWN 

405 message = f"Error getting workflow status for id {wms_workflow_id}: ret = {ret}" 

406 else: 

407 message = "" 

408 head = tasks[0] 

409 workflow_status = head["status"]["attributes"]["_name_"] 

410 if workflow_status in ["Finished"]: 

411 state = WmsStates.SUCCEEDED 

412 elif workflow_status in ["Failed", "Expired", "SubFinished"]: 

413 state = WmsStates.FAILED 

414 elif workflow_status in ["Cancelled"]: 

415 state = WmsStates.DELETED 

416 elif workflow_status in ["Suspended"]: 

417 state = WmsStates.HELD 

418 else: 

419 state = WmsStates.RUNNING 

420 

421 return state, message 

422 

423 

424class PandaBpsWmsWorkflow(BaseWmsWorkflow): 

425 """A single Panda based workflow. 

426 

427 Parameters 

428 ---------- 

429 name : `str` 

430 Unique name for Workflow. 

431 config : `lsst.ctrl.bps.BpsConfig` 

432 BPS configuration that includes necessary submit/runtime information. 

433 """ 

434 

435 def __init__(self, name, config=None): 

436 super().__init__(name, config) 

437 self.files_to_pre_stage = {} # src, dest 

438 self.idds_client_workflow = IDDS_client_workflow(name=name) 

439 self.run_attrs = {} 

440 

441 @classmethod 

442 def from_generic_workflow(cls, config, generic_workflow, out_prefix, service_class): 

443 # Docstring inherited from BaseWmsWorkflow.from_generic_workflow. 

444 wms_workflow = cls(generic_workflow.name, config) 

445 

446 if generic_workflow.run_attrs: 

447 wms_workflow.run_attrs.update(generic_workflow.run_attrs) 

448 

449 files, dag_sink_work, task_count = add_idds_work( 

450 config, generic_workflow, wms_workflow.idds_client_workflow 

451 ) 

452 wms_workflow.files_to_pre_stage.update(files) 

453 

454 files = add_final_idds_work( 

455 config, generic_workflow, wms_workflow.idds_client_workflow, dag_sink_work, task_count + 1, 1 

456 ) 

457 wms_workflow.files_to_pre_stage.update(files) 

458 

459 return wms_workflow 

460 

461 def write(self, out_prefix): 

462 # Docstring inherited from BaseWmsWorkflow.write. 

463 with open(os.path.join(out_prefix, "panda_workflow.pickle"), "wb") as fh: 

464 pickle.dump(self, fh)