Coverage for python/lsst/rucio/register/script.py: 71%

525 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-11-07 01:39 -0800

1# This file is part of rucio_register 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (http://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 program is free software: you can redistribute it and/or modify 

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

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

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

21 

22 

23import datetime 

24import itertools 

25import json 

26import logging 

27import multiprocessing 

28import os 

29import sys 

30import threading 

31import time 

32import urllib.request 

33from concurrent.futures import ProcessPoolExecutor, as_completed 

34from typing import Any 

35 

36import click 

37import yaml 

38 

39from lsst.daf.butler import Butler, CollectionType, _exceptions 

40from lsst.daf.butler.cli.opt import log_level_option, options_file_option, query_datasets_options 

41from lsst.daf.butler.script.queryDatasets import QueryDatasets 

42from lsst.resources import ResourcePath 

43from lsst.rucio.register.data_type import DataType 

44from lsst.rucio.register.rucio_interface import RucioInterface 

45from lsst.rucio.register.rucio_register_config import RucioRegisterConfig 

46 

47_thread_local = threading.local() 

48 

49logger = logging.getLogger(__name__) 

50_FORMAT = ( 

51 "%(levelname) -10s %(asctime)s.%(msecs)03dZ %(name) -30s %(funcName) -35s %(lineno) -5d: %(message)s" 

52) 

53 

54RUCIO_REGISTER_CONFIG = "RUCIO_REGISTER_CONFIG" 

55_MSG = "environment variable not set, and no configuration was specified on the command line" 

56 

57 

58def register_options(func): 

59 """Decorator for CLI options that are common across commands.""" 

60 func = click.option( 

61 "--backoff-max-tries", 

62 "--max-retries", 

63 "max_retries", 

64 required=False, 

65 type=int, 

66 default=RucioInterface.DEFAULT_RETRIES, 

67 show_default=True, 

68 help="maximum number of times to retry failed registration", 

69 )(func) 

70 func = click.option( 

71 "--backoff-max-value", 

72 required=False, 

73 type=int, 

74 default=RucioInterface.DEFAULT_BACKOFF_MAX_VALUE, 

75 show_default=True, 

76 help="maximum backoff value in seconds", 

77 )(func) 

78 func = click.option( 

79 "--backoff-factor", 

80 required=False, 

81 type=float, 

82 default=RucioInterface.DEFAULT_BACKOFF_FACTOR, 

83 show_default=True, 

84 help="multiplicative factor to backoff by", 

85 )(func) 

86 func = click.option( 

87 "--timeout", 

88 required=False, 

89 type=float, 

90 default=RucioInterface.DEFAULT_TIMEOUT, 

91 show_default=True, 

92 help=( 

93 "HTTP request timeout in seconds for Rucio client operations " 

94 "(ReplicaClient and DIDClient bulk replica registration and dataset attachment)" 

95 ), 

96 )(func) 

97 func = click.option( 

98 "--chunk-size", 

99 required=False, 

100 type=int, 

101 default=500, 

102 help="number of replica requests to make at once", 

103 )(func) 

104 func = click.option( 

105 "--rucio-register-config", required=False, type=str, help="registration configuration file" 

106 )(func) 

107 func = click.option( 

108 "--rucio-dataset", required=False, type=str, help="rucio dataset to register files to" 

109 )(func) 

110 return func 

111 

112 

113def chunks(refs, chunk_size): 

114 it = iter(refs) 

115 while True: 

116 chunk = list(itertools.islice(it, chunk_size)) 

117 if not chunk: 

118 return 

119 yield chunk 

120 

121 

122_rucio_interface_cache = {} 

123 

124 

125def _get_rucio_interface( 

126 repo, 

127 rucio_register_config, 

128 rubin_butler_type, 

129 clear_is_new=False, 

130 retries=RucioInterface.DEFAULT_RETRIES, 

131 kwargs=None, 

132): 

133 if kwargs: 

134 backoff_factor = kwargs.get("backoff_factor", RucioInterface.DEFAULT_BACKOFF_FACTOR) 

135 backoff_max_value = kwargs.get("backoff_max_value", RucioInterface.DEFAULT_BACKOFF_MAX_VALUE) 

136 backoff_max_tries = kwargs.get("backoff_max_tries", kwargs.get("max_retries", retries)) 

137 timeout = kwargs.get("timeout", RucioInterface.DEFAULT_TIMEOUT) 

138 if backoff_max_tries is not None: 138 ↛ 146line 138 didn't jump to line 146 because the condition on line 138 was always true

139 retries = backoff_max_tries 

140 else: 

141 backoff_factor = RucioInterface.DEFAULT_BACKOFF_FACTOR 

142 backoff_max_value = RucioInterface.DEFAULT_BACKOFF_MAX_VALUE 

143 backoff_max_tries = retries 

144 timeout = RucioInterface.DEFAULT_TIMEOUT 

145 

146 config_file = os.environ.get(RUCIO_REGISTER_CONFIG, rucio_register_config) 

147 cache_key = ( 

148 os.getpid(), 

149 repo, 

150 config_file, 

151 rubin_butler_type, 

152 clear_is_new, 

153 retries, 

154 backoff_factor, 

155 backoff_max_value, 

156 timeout, 

157 ) 

158 if cache_key in _rucio_interface_cache: 

159 logger.debug("Reusing cached RucioInterface/Butler for key: %s", cache_key) 

160 return _rucio_interface_cache[cache_key] 

161 

162 if config_file is None: 162 ↛ 163line 162 didn't jump to line 163 because the condition on line 162 was never true

163 raise RuntimeError(f"{RUCIO_REGISTER_CONFIG} {_MSG}") 

164 

165 config = RucioRegisterConfig(config_file) 

166 

167 rucio_rse = config.rucio_rse 

168 scope = config.scope 

169 rse_root = config.rse_root 

170 dtn_url = config.dtn_url 

171 

172 butler = None 

173 if repo: 173 ↛ 177line 173 didn't jump to line 177 because the condition on line 173 was always true

174 butler = Butler(repo) 

175 

176 # create RucioInterface object used to register replicas into datasets 

177 ri = RucioInterface( 

178 butler=butler, 

179 rucio_rse=rucio_rse, 

180 scope=scope, 

181 rse_root=rse_root, 

182 dtn_url=dtn_url, 

183 rubin_butler_type=rubin_butler_type, 

184 clear_is_new=clear_is_new, 

185 retries=retries, 

186 timeout=timeout, 

187 ) 

188 ri.set_backoff(factor=backoff_factor, max_value=backoff_max_value, max_tries=retries) 

189 

190 _rucio_interface_cache[cache_key] = (ri, butler) 

191 return ri, butler 

192 

193 

194def worker_register_replicas( 

195 repo, rucio_config, butler_type, rucio_dataset, refs_list, retries, clear_is_new=False, kwargs=None 

196): 

197 ri, _ = _get_rucio_interface( 

198 repo, rucio_config, butler_type, clear_is_new=clear_is_new, retries=retries, kwargs=kwargs 

199 ) 

200 return ri.register_as_replicas(rucio_dataset, refs_list) 

201 

202 

203def worker_register_zips( 

204 repo, rucio_config, butler_type, rucio_dataset, zip_files, retries, clear_is_new=False, kwargs=None 

205): 

206 ri, _ = _get_rucio_interface( 

207 repo, rucio_config, butler_type, clear_is_new=clear_is_new, retries=retries, kwargs=kwargs 

208 ) 

209 return ri.register_zips(rucio_dataset, zip_files) 

210 

211 

212def worker_register_dims( 

213 repo, rucio_config, butler_type, rucio_dataset, dim_files, retries, clear_is_new=False, kwargs=None 

214): 

215 ri, _ = _get_rucio_interface( 

216 repo, rucio_config, butler_type, clear_is_new=clear_is_new, retries=retries, kwargs=kwargs 

217 ) 

218 return ri.register_dims(rucio_dataset, dim_files) 

219 

220 

221def _register( 

222 repo, 

223 rucio_register_config, 

224 rubin_butler_type, 

225 dataset_refs, 

226 chunk_size, 

227 rucio_dataset, 

228 max_workers=1, 

229 retries=RucioInterface.DEFAULT_RETRIES, 

230 stats=None, 

231 clear_is_new=False, 

232 kwargs=None, 

233): 

234 failed_uuids = [] 

235 registered_count = 0 

236 

237 total_refs = None 

238 try: 

239 total_refs = len(dataset_refs) 

240 except TypeError: 

241 pass 

242 

243 if total_refs is not None: 243 ↛ 249line 243 didn't jump to line 249 because the condition on line 243 was always true

244 logger.info( 

245 f"Starting registration of {total_refs} datasets to {rucio_dataset} " 

246 f"(chunk_size={chunk_size}, max_workers={max_workers})" 

247 ) 

248 else: 

249 logger.info( 

250 f"Starting registration of datasets to {rucio_dataset} " 

251 f"(chunk_size={chunk_size}, max_workers={max_workers})" 

252 ) 

253 

254 ref_chunks = list(chunks(dataset_refs, chunk_size)) 

255 total_chunks = len(ref_chunks) 

256 

257 if max_workers > 1 and total_chunks > 1: 257 ↛ 258line 257 didn't jump to line 258 because the condition on line 257 was never true

258 mp_context = multiprocessing.get_context("spawn") 

259 log_level = kwargs.get("log_level", None) if kwargs else None 

260 with ProcessPoolExecutor( 

261 max_workers=max_workers, 

262 mp_context=mp_context, 

263 initializer=_set_log_level, 

264 initargs=(log_level,), 

265 ) as executor: 

266 future_to_chunk = { 

267 executor.submit( 

268 worker_register_replicas, 

269 repo, 

270 rucio_register_config, 

271 rubin_butler_type, 

272 rucio_dataset, 

273 refs, 

274 retries, 

275 clear_is_new, 

276 kwargs, 

277 ): (i, refs) 

278 for i, refs in enumerate(ref_chunks, 1) 

279 } 

280 

281 for future in as_completed(future_to_chunk): 

282 chunk_idx, refs = future_to_chunk[future] 

283 try: 

284 count = future.result() 

285 registered_count += count 

286 logger.info(f"Chunk {chunk_idx}/{total_chunks} completed: {count} datasets registered") 

287 except Exception as e: 

288 logger.error(f"Chunk {chunk_idx}/{total_chunks} failed permanently: {e}") 

289 chunk_uuids = [str(r.id) for r in refs] 

290 failed_uuids.extend(chunk_uuids) 

291 else: 

292 for chunk_idx, refs in enumerate(ref_chunks, 1): 

293 refs_list = list(refs) 

294 try: 

295 count = worker_register_replicas( 

296 repo, 

297 rucio_register_config, 

298 rubin_butler_type, 

299 rucio_dataset, 

300 refs_list, 

301 retries, 

302 clear_is_new, 

303 kwargs, 

304 ) 

305 registered_count += count 

306 logger.info(f"Chunk {chunk_idx}/{total_chunks} completed: {count} datasets registered") 

307 except Exception as e: 

308 logger.error(f"Chunk {chunk_idx}/{total_chunks} failed permanently: {e}") 

309 chunk_uuids = [str(r.id) for r in refs_list] 

310 failed_uuids.extend(chunk_uuids) 

311 

312 if stats is not None: 312 ↛ 317line 312 didn't jump to line 317 because the condition on line 312 was always true

313 stats["registered"] = registered_count 

314 stats["failed"] = len(failed_uuids) 

315 stats["failures"] = failed_uuids 

316 

317 if failed_uuids: 317 ↛ 318line 317 didn't jump to line 318 because the condition on line 317 was never true

318 logger.error( 

319 f"Registration summary for {rucio_dataset} - " 

320 f"registered: {registered_count}, failed: {len(failed_uuids)}" 

321 ) 

322 logger.error(f"Failed dataset UUIDs: {failed_uuids}") 

323 else: 

324 logger.info(f"Registration summary for {rucio_dataset} - registered: {registered_count}, failed: 0") 

325 

326 

327def _set_log_level(log_level: Any) -> None: 

328 if isinstance(log_level, dict): 

329 log_level = log_level.get(None) or (next(iter(log_level.values()), None) if log_level else None) 

330 if isinstance(log_level, str): 

331 handler = logging.StreamHandler(sys.stdout) 

332 handler.setFormatter(logging.Formatter(_FORMAT)) 

333 log = logging.getLogger() 

334 log.handlers.clear() 

335 log.addHandler(handler) 

336 log.setLevel(log_level.upper()) 

337 

338 

339def _limit_dataset_types(dataset_types: list[str], max_dataset_types: int | None) -> list[str]: 

340 """Limit the list of dataset types to max_dataset_types if provided.""" 

341 if max_dataset_types is None: 

342 return dataset_types 

343 return list(dataset_types[:max_dataset_types]) 

344 

345 

346@click.group(context_settings={"help_option_names": ["-h", "--help"]}) 

347def main(): 

348 pass 

349 

350 

351@main.command() 

352@click.option("--repo", required=True, type=str, help="butler repository") 

353@register_options 

354@click.option( 

355 "--max-workers", 

356 required=False, 

357 type=int, 

358 default=5, 

359 help="number of concurrent workers for registration", 

360) 

361@click.option( 

362 "--clear-is-new", 

363 is_flag=True, 

364 default=False, 

365 help="Set is_new metadata to None on Rucio dataset DIDs post-registration", 

366) 

367@log_level_option() 

368@options_file_option() 

369@query_datasets_options(repo=False, showUri=True, useArguments=False) 

370def data_products(**kwargs: Any) -> None: 

371 log_level = kwargs.get("log_level", None) 

372 _set_log_level(log_level) 

373 

374 rucio_dataset = kwargs.get("rucio_dataset", None) 

375 if not rucio_dataset: 

376 raise click.UsageError("--rucio-dataset must be provided for data_products.") 

377 

378 rucio_register_config = kwargs.get("rucio_register_config", None) 

379 chunk_size = kwargs.get("chunk_size", 500) 

380 max_workers = kwargs.get("max_workers", 5) 

381 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES)) 

382 clear_is_new = kwargs.get("clear_is_new", False) 

383 

384 repo = kwargs.get("repo", None) 

385 collections = kwargs.get("collections", None) 

386 where = kwargs.get("where", None) 

387 find_first = kwargs.get("find_first", None) 

388 limit = kwargs.get("limit", None) 

389 order_by = kwargs.get("order_by", None) 

390 dataset_type = kwargs.get("dataset_type", None) 

391 

392 ri, butler = _get_rucio_interface( 

393 repo, 

394 rucio_register_config, 

395 DataType.DATA_PRODUCT, 

396 clear_is_new=clear_is_new, 

397 retries=max_retries, 

398 kwargs=kwargs, 

399 ) 

400 

401 query = QueryDatasets( 

402 butler=butler, 

403 glob=dataset_type, 

404 collections=collections, 

405 where=where, 

406 find_first=find_first, 

407 limit=limit, 

408 order_by=order_by, 

409 show_uri=False, 

410 with_dimension_records=True, 

411 ) 

412 

413 dataset_refs = itertools.chain(*query.getDatasets()) 

414 

415 stats = {} 

416 _register( 

417 repo, 

418 rucio_register_config, 

419 DataType.DATA_PRODUCT, 

420 dataset_refs, 

421 chunk_size, 

422 rucio_dataset, 

423 max_workers=max_workers, 

424 retries=max_retries, 

425 stats=stats, 

426 clear_is_new=clear_is_new, 

427 kwargs=kwargs, 

428 ) 

429 

430 

431@main.command() 

432@click.option("--repo", required=True, type=str, help="butler repository") 

433@register_options 

434@click.option( 

435 "--max-workers", 

436 required=False, 

437 type=int, 

438 default=5, 

439 help="number of concurrent workers for registration", 

440) 

441@click.option( 

442 "--uuidlist", 

443 required=False, 

444 type=str, 

445 help=( 

446 "filename of a list of butler dataset UUIDs (e.g. generated by " 

447 "'auto-register --dry-run') to be registered to the rucio dataset." 

448 ), 

449) 

450@log_level_option() 

451@options_file_option() 

452def dataset_list(**kwargs: Any) -> None: 

453 log_level = kwargs.get("log_level", None) 

454 _set_log_level(log_level) 

455 

456 rucio_register_config = kwargs.get("rucio_register_config", None) 

457 chunk_size = kwargs.get("chunk_size", 500) 

458 max_workers = kwargs.get("max_workers", 5) 

459 uuidlist = kwargs.get("uuidlist", None) 

460 if not uuidlist: 460 ↛ 461line 460 didn't jump to line 461 because the condition on line 460 was never true

461 raise click.UsageError("uuidlist is None. Please provide a valid file containing UUIDs.") 

462 

463 logger.info(f"Reading UUIDs from {uuidlist}") 

464 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES)) 

465 

466 repo = kwargs.get("repo", None) 

467 

468 # Infer dataset from file header if available 

469 uuids = [] 

470 inferred_dataset = None 

471 with open(uuidlist) as f: 

472 content = f.read().strip() 

473 if content.startswith("[") or content.startswith("{"): 473 ↛ 483line 473 didn't jump to line 483 because the condition on line 473 was always true

474 try: 

475 data = json.loads(content) 

476 if isinstance(data, list): 476 ↛ 478line 476 didn't jump to line 478 because the condition on line 476 was always true

477 uuids = [str(item.get("id", item) if isinstance(item, dict) else item) for item in data] 

478 elif isinstance(data, dict): 

479 uuids = [str(k) for k in data.keys()] 

480 except json.JSONDecodeError: 

481 pass 

482 

483 if not uuids: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true

484 for line in content.splitlines(): 

485 stripped = line.strip() 

486 if stripped.startswith("# Dataset/"): 

487 parts = stripped.split() 

488 if len(parts) >= 2: 

489 inferred_dataset = parts[1] 

490 elif not line.lstrip().startswith("#") and stripped: 

491 clean_uuid = stripped.strip('"').strip("'").rstrip(",").strip() 

492 if clean_uuid and clean_uuid not in ("[", "]"): 

493 uuids.append(clean_uuid) 

494 

495 rucio_dataset = kwargs.get("rucio_dataset") or inferred_dataset 

496 if not rucio_dataset: 496 ↛ 497line 496 didn't jump to line 497 because the condition on line 496 was never true

497 raise click.UsageError( 

498 "--rucio-dataset must be provided on CLI or via '# Dataset/...' header in uuidlist." 

499 ) 

500 

501 ri, butler = _get_rucio_interface( 

502 repo, rucio_register_config, DataType.DATA_PRODUCT, retries=max_retries, kwargs=kwargs 

503 ) 

504 if butler is None: 504 ↛ 505line 504 didn't jump to line 505 because the condition on line 504 was never true

505 raise click.UsageError("Butler instance is None. Please check the repository path.") 

506 

507 dataset_refs = butler.get_many_datasets(uuids) 

508 

509 stats = {} 

510 _register( 

511 repo, 

512 rucio_register_config, 

513 DataType.DATA_PRODUCT, 

514 dataset_refs, 

515 chunk_size, 

516 rucio_dataset, 

517 max_workers=max_workers, 

518 retries=max_retries, 

519 stats=stats, 

520 kwargs=kwargs, 

521 ) 

522 logger.info(f"Successfully registered {len(dataset_refs)} datasets from {uuidlist} to {rucio_dataset}") 

523 

524 

525@main.command() 

526@click.option("--repo", required=True, type=str, help="butler repository") 

527@register_options 

528@click.option( 

529 "--max-workers", 

530 required=False, 

531 type=int, 

532 default=5, 

533 help="number of concurrent workers for registration", 

534) 

535@log_level_option() 

536@options_file_option() 

537@query_datasets_options(repo=False, showUri=True) 

538def raws(**kwargs: Any) -> None: 

539 log_level = kwargs.get("log_level", None) 

540 _set_log_level(log_level) 

541 

542 rucio_dataset = kwargs.get("rucio_dataset") 

543 if not rucio_dataset: 

544 raise click.UsageError("--rucio-dataset must be provided for raws.") 

545 

546 rucio_register_config = kwargs.get("rucio_register_config") 

547 chunk_size = kwargs.get("chunk_size", 500) 

548 max_workers = kwargs.get("max_workers", 5) 

549 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES)) 

550 

551 repo = kwargs["repo"] 

552 

553 ri, butler = _get_rucio_interface( 

554 repo, rucio_register_config, DataType.RAW_FILE, retries=max_retries, kwargs=kwargs 

555 ) 

556 

557 query_kwargs = { 

558 k: v 

559 for k, v in kwargs.items() 

560 if k 

561 not in ( 

562 "backoff_factor", 

563 "backoff_max_value", 

564 "backoff_max_tries", 

565 "max_retries", 

566 "backoff-factor", 

567 "backoff-max-value", 

568 "backoff-max-tries", 

569 "max_workers", 

570 "rucio_dataset", 

571 "rucio_register_config", 

572 "chunk_size", 

573 "timeout", 

574 "log_level", 

575 ) 

576 } 

577 

578 # chain is needed to flatten the list of lists returned by getDatasets() 

579 dataset_refs = itertools.chain.from_iterable(QueryDatasets(**query_kwargs).getDatasets()) 

580 

581 stats = {} 

582 _register( 

583 repo, 

584 rucio_register_config, 

585 DataType.RAW_FILE, 

586 dataset_refs, 

587 chunk_size, 

588 rucio_dataset, 

589 max_workers=max_workers, 

590 retries=max_retries, 

591 stats=stats, 

592 kwargs=kwargs, 

593 ) 

594 

595 

596@main.command() 

597@register_options 

598@click.option("--zip-file", required=True, help="zip file to register") 

599@log_level_option() 

600def zips(**kwargs: Any) -> None: 

601 log_level = kwargs.get("log_level", None) 

602 _set_log_level(log_level) 

603 

604 rucio_dataset = kwargs.get("rucio_dataset") 

605 if not rucio_dataset: 

606 raise click.UsageError("--rucio-dataset must be provided for zips.") 

607 

608 zip_file = kwargs.get("zip_file") 

609 rucio_register_config = kwargs.get("rucio_register_config") 

610 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES)) 

611 

612 ri, butler = _get_rucio_interface( 

613 None, rucio_register_config, DataType.ZIP_FILE, retries=max_retries, kwargs=kwargs 

614 ) 

615 

616 try: 

617 count = worker_register_zips( 

618 repo=None, 

619 rucio_config=rucio_register_config, 

620 butler_type=DataType.ZIP_FILE, 

621 rucio_dataset=rucio_dataset, 

622 zip_files=[ResourcePath(zip_file)], 

623 retries=max_retries, 

624 kwargs=kwargs, 

625 ) 

626 logger.info(f"Batch zip registration summary for {rucio_dataset} - registered: {count}, failed: 0") 

627 logger.info(f"Successfully registered zip file {zip_file} to {rucio_dataset}") 

628 except Exception as e: 

629 logger.error(f"Failed to register zip file {zip_file}: {e}", exc_info=True) 

630 

631 

632@main.command() 

633@register_options 

634@click.option("--dimension-file", required=True, help="dimension file to register") 

635@log_level_option() 

636def dimensions(**kwargs: Any) -> None: 

637 log_level = kwargs.get("log_level", None) 

638 _set_log_level(log_level) 

639 

640 rucio_dataset = kwargs.get("rucio_dataset") 

641 if not rucio_dataset: 

642 raise click.UsageError("--rucio-dataset must be provided for dimensions.") 

643 

644 dim_file = kwargs.get("dimension_file") 

645 rucio_register_config = kwargs.get("rucio_register_config") 

646 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES)) 

647 

648 ri, butler = _get_rucio_interface( 

649 None, rucio_register_config, DataType.DIM_FILE, retries=max_retries, kwargs=kwargs 

650 ) 

651 

652 try: 

653 count = worker_register_dims( 

654 repo=None, 

655 rucio_config=rucio_register_config, 

656 butler_type=DataType.DIM_FILE, 

657 rucio_dataset=rucio_dataset, 

658 dim_files=[ResourcePath(dim_file)], 

659 retries=max_retries, 

660 kwargs=kwargs, 

661 ) 

662 logger.info( 

663 f"Batch dimension registration summary for {rucio_dataset} - registered: {count}, failed: 0" 

664 ) 

665 logger.info(f"Successfully registered dimension file {dim_file} to {rucio_dataset}") 

666 except Exception as e: 

667 logger.error(f"Failed to register dimension file {dim_file}: {e}", exc_info=True) 

668 

669 

670def _process_refs_chunk( 

671 refs_chunk, 

672 rucio_dataset_name, 

673 dry_run, 

674 out_dir, 

675 repo, 

676 rucio_register_config, 

677 chunk_size, 

678 max_retries, 

679 clear_is_new=False, 

680 kwargs=None, 

681): 

682 """Register or dry-run write a chunk of refs.""" 

683 local_stats = {"registered": 0, "failed": 0, "failures": []} 

684 if dry_run: 

685 lines = [f"# {rucio_dataset_name} {len(refs_chunk)}\n"] 

686 for ref in refs_chunk: 

687 lines.append(f"{str(ref.id)}\n") 

688 

689 # Sanitize repo path for use in output directory structure 

690 sanitized_repo = repo.strip("/").replace("/", "_") 

691 # Sanitize rucio_dataset_name for use as a filename 

692 sanitized_filename = rucio_dataset_name.replace("/", "-") + ".txt" 

693 

694 uuiddir = os.path.join(out_dir, sanitized_repo, "auto_register") 

695 os.makedirs(uuiddir, exist_ok=True) 

696 uuidfile = os.path.join(uuiddir, sanitized_filename) 

697 

698 with open(uuidfile, "w") as f: 

699 f.write("".join(lines)) 

700 logger.info(f"Dry-run: Wrote {len(refs_chunk)} UUIDs to {uuidfile}") 

701 else: 

702 logger.info(f"Registering {len(refs_chunk)} items to dynamic Rucio dataset {rucio_dataset_name}") 

703 # When called from auto_register, we rely on the outer ProcessPool 

704 # for parallelism (`batch_workers`). We set the inner worker count 

705 # to 1 to avoid nested process pools, which can cause deadlocks. 

706 # This is now the default for _register. 

707 _register( 

708 repo, 

709 rucio_register_config, 

710 DataType.DATA_PRODUCT, 

711 refs_chunk, 

712 chunk_size, 

713 rucio_dataset_name, 

714 retries=max_retries, 

715 stats=local_stats, 

716 clear_is_new=clear_is_new, 

717 kwargs=kwargs, 

718 ) 

719 return local_stats 

720 

721 

722def _process_auto_register_batch(**kwargs: Any) -> dict[str, int]: 

723 """Worker function to process a batch of dataset refs for 

724 auto-registration. 

725 """ 

726 refs = kwargs.get("refs") 

727 ds_name = kwargs.get("ds_name") 

728 clear_is_new = kwargs.get("clear_is_new", False) 

729 repo = kwargs.get("repo") 

730 rucio_register_config = kwargs.get("rucio_register_config") 

731 dry_run = kwargs.get("dry_run") 

732 out_dir = kwargs.get("out_dir") 

733 chunk_size = kwargs.get("chunk_size") 

734 max_retries = kwargs.get("max_retries") 

735 logger.info( 

736 "Worker processing pre-queried sub-batch of size %d for Rucio dataset %s", 

737 len(refs), 

738 ds_name, 

739 ) 

740 return _process_refs_chunk( 

741 refs, 

742 ds_name, 

743 dry_run, 

744 out_dir, 

745 repo, 

746 rucio_register_config, 

747 chunk_size, 

748 max_retries, 

749 clear_is_new=clear_is_new, 

750 kwargs=kwargs, 

751 ) 

752 

753 

754def _load_curated_dataset_types(transfer_list: str) -> set[str]: 

755 content = "" 

756 if transfer_list.startswith(("http://", "https://")): 

757 try: 

758 logger.info("Fetching curated transfer list from URL: %s", transfer_list) 

759 with urllib.request.urlopen(transfer_list, timeout=15.0) as response: 

760 content = response.read().decode("utf-8") 

761 except Exception as e: 

762 logger.error("Failed to fetch curated transfer list from URL: %s", e) 

763 raise 

764 else: 

765 try: 

766 logger.info("Loading curated transfer list from local file: %s", transfer_list) 

767 with open(transfer_list) as f: 

768 content = f.read() 

769 except Exception as e: 

770 logger.error("Failed to read curated transfer list from local file: %s", e) 

771 raise 

772 

773 try: 

774 data = yaml.safe_load(content) 

775 except Exception as e: 

776 logger.error("Failed to parse curated transfer list YAML: %s", e) 

777 raise 

778 

779 curated_types = set() 

780 

781 def _traverse(node): 

782 if isinstance(node, list): 

783 for item in node: 

784 if isinstance(item, str): 784 ↛ 783line 784 didn't jump to line 783 because the condition on line 784 was always true

785 curated_types.add(item) 

786 elif isinstance(node, dict): 786 ↛ exitline 786 didn't return from function '_traverse' because the condition on line 786 was always true

787 for val in node.values(): 

788 _traverse(val) 

789 

790 _traverse(data) 

791 logger.info("Loaded %d curated dataset types from transfer list", len(curated_types)) 

792 return curated_types 

793 

794 

795def _query_butler_task(repo: str, task: dict[str, Any]) -> tuple[dict[str, Any], list[Any]]: 

796 collection = task["collection"] 

797 dataset_type = task["dataset_type"] 

798 where = task["where"] 

799 

800 try: 

801 if not hasattr(_thread_local, "butlers"): 

802 _thread_local.butlers = {} 

803 if repo not in _thread_local.butlers: 

804 _thread_local.butlers[repo] = Butler(repo) 

805 butler = _thread_local.butlers[repo] 

806 

807 raw_res = butler.query_datasets( 

808 dataset_type=dataset_type, 

809 collections=collection, 

810 find_first=False, 

811 where=where, 

812 order_by="ingest_date", 

813 limit=None, 

814 explain=False, 

815 ) 

816 return task, list(raw_res) 

817 except _exceptions.EmptyQueryResultError as e: 

818 logger.debug("Empty query result for task %s: %s", task, e) 

819 return task, [] 

820 except Exception as e: 

821 logger.exception("Exception during Butler query task: %s", e) 

822 return task, [] 

823 

824 

825def _format_query_timestamp(date_str: str) -> str: 

826 """Format a date/datetime string into a valid Butler T'...' timestamp.""" 

827 date_str = date_str.strip() 

828 if "T" in date_str: 828 ↛ 829line 828 didn't jump to line 829 because the condition on line 828 was never true

829 return date_str 

830 if " " in date_str: 830 ↛ 831line 830 didn't jump to line 831 because the condition on line 830 was never true

831 return date_str.replace(" ", "T") 

832 return f"{date_str}T00:00:00" 

833 

834 

835@main.command() 

836@click.option("--repo", required=True, type=str, help="butler repository") 

837@register_options 

838@click.option( 

839 "--dataset-name-prefix", 

840 required=False, 

841 type=str, 

842 default=None, 

843 help="Custom prefix for auto-register datasets, replacing 'Dataset/{collection}'", 

844) 

845@click.option( 

846 "--max-workers", 

847 required=False, 

848 type=int, 

849 default=5, 

850 help="Number of concurrent workers for processing batches.", 

851) 

852@click.option( 

853 "--start-date", 

854 required=False, 

855 default="2000-01-01", 

856 type=str, 

857 help="Start date (YYYY-MM-DD)", 

858) 

859@click.option( 

860 "--cutoff-date", 

861 required=False, 

862 default=datetime.datetime.now().strftime("%Y-%m-%d 23:59:59"), 

863 type=str, 

864 help="Cutoff date (YYYY-MM-DD)", 

865) 

866@click.option("--root-chain", required=True, type=str, help="Collection expression") 

867@click.option("--df-name", default="USDF", type=str, help="Data Facility name") 

868@click.option( 

869 "--max-did-per-dataset", 

870 default=50_000, 

871 type=int, 

872 help="Max items per Rucio dataset", 

873) 

874@click.option( 

875 "--max-dataset-types", 

876 default=None, 

877 type=int, 

878 help="Maximum number of discovered dataset types to process per collection (for testing)", 

879) 

880@click.option( 

881 "--split-size", 

882 default=2000, 

883 type=int, 

884 help="Sub-batch size for parallelizing large datasets", 

885) 

886@click.option( 

887 "--transfer-list", 

888 required=False, 

889 type=str, 

890 default="", 

891 help="URL or local path to a curated YAML list of dataset types to register", 

892) 

893@click.option( 

894 "--dry-run", is_flag=True, help="Generate txt files for 'dataset-list' instead of registering directly" 

895) 

896@click.option("--out-dir", default="./uuids", type=str, help="Output directory for dry-run") 

897@click.option( 

898 "--clear-is-new", 

899 is_flag=True, 

900 default=False, 

901 help="Set is_new metadata to None on Rucio dataset DIDs post-registration", 

902) 

903@log_level_option() 

904@options_file_option() 

905def auto_register(**kwargs: Any) -> None: 

906 """ 

907 Auto-discovers and registers datasets chronologically by quarters, 

908 dynamically partitioning massive collections into chunked Rucio datasets. 

909 

910 If --dry-run is used, it generates text files that can be fed directly 

911 into the 'dataset-list' command via the --uuidlist option. 

912 """ 

913 log_level = kwargs.get("log_level", None) 

914 _set_log_level(log_level) 

915 logger.debug("auto-register namespace / CLI options and defaults: %s", kwargs) 

916 

917 repo = kwargs["repo"] 

918 rucio_register_config = kwargs.get("rucio_register_config") 

919 chunk_size = kwargs.get("chunk_size", 500) 

920 max_workers = kwargs.get("max_workers", 5) 

921 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES)) 

922 backoff_factor = kwargs.get("backoff_factor", RucioInterface.DEFAULT_BACKOFF_FACTOR) 

923 backoff_max_value = kwargs.get("backoff_max_value", RucioInterface.DEFAULT_BACKOFF_MAX_VALUE) 

924 clear_is_new = kwargs.get("clear_is_new", False) 

925 

926 start_date = _format_query_timestamp(kwargs["start_date"]) 

927 cutoff_date = _format_query_timestamp(kwargs["cutoff_date"]) 

928 root_chain = kwargs["root_chain"] 

929 df_name = kwargs["df_name"] 

930 max_dataset_types = kwargs.get("max_dataset_types") 

931 split_size = kwargs.get("split_size", 2000) 

932 dry_run = kwargs["dry_run"] 

933 out_dir = kwargs["out_dir"] 

934 dataset_name_prefix = kwargs.get("dataset_name_prefix", None) 

935 

936 butler = Butler(repo) 

937 transfer_list = kwargs.get("transfer_list", "") 

938 curated_types = None 

939 if transfer_list: 

940 curated_types = _load_curated_dataset_types(transfer_list) 

941 start_time = time.time() 

942 

943 # Aggregate at end to avoid thread race conditions 

944 global_stats = {"registered": 0, "failed": 0, "failures": []} 

945 logger.info("Auto-register processing started at %.3f seconds", start_time) 

946 

947 # Calculate covered quarters 

948 years = [int(start_date.split("-")[0]), int(cutoff_date.split("-")[0])] 

949 quarters = [] 

950 for year in range(years[0], years[-1] + 1): 

951 for q in ["01-01", "04-01", "07-01", "10-01"]: 

952 quarters.append(_format_query_timestamp(f"{year}-{q}")) 

953 quarters.append(_format_query_timestamp(f"{years[-1] + 1}-01-01")) 

954 

955 q_skip = 0 

956 for i in range(len(quarters) - 1): 956 ↛ 961line 956 didn't jump to line 961 because the loop on line 956 didn't complete

957 if quarters[i + 1] > start_date: 

958 q_skip = i 

959 break 

960 

961 covered_quarters = quarters[q_skip:] 

962 

963 # Discover all collections matching root-chain glob 

964 matched_collections = list(butler.collections.query(root_chain, collection_types={CollectionType.RUN})) 

965 logger.info(f"Discovered {len(matched_collections)} matching collections for '{root_chain}'") 

966 

967 for collection in matched_collections: 

968 logger.info( 

969 "Processing collection '%s' across %d time quarters", collection, len(covered_quarters) - 1 

970 ) 

971 

972 dataset_types = [] 

973 try: 

974 try: 

975 info = butler.collections.get_info(collection, include_summary=True) 

976 except TypeError: 

977 info = butler.collections.get_info(collection) 

978 if info is not None: 

979 raw_types = getattr(info, "dataset_types", None) 

980 if raw_types is None and hasattr(info, "datasetTypes"): 

981 raw_types = getattr(info, "datasetTypes", None) 

982 

983 if raw_types is not None: 983 ↛ 999line 983 didn't jump to line 999 because the condition on line 983 was always true

984 discovered_types = [ 

985 getattr(dt, "name", str(dt)) if not isinstance(dt, str) else dt for dt in raw_types 

986 ] 

987 if curated_types is not None: 

988 curated_set = set(curated_types) 

989 dataset_types = [dt for dt in discovered_types if dt in curated_set] 

990 logger.info( 

991 "Filtered %d collection dataset types down to %d matching " 

992 "curated transfer list for collection '%s'", 

993 len(discovered_types), 

994 len(dataset_types), 

995 collection, 

996 ) 

997 else: 

998 dataset_types = discovered_types 

999 elif curated_types is not None: 

1000 dataset_types = list(curated_types) 

1001 logger.info( 

1002 "Collection '%s' dataset_types is None; " 

1003 "falling back to %d curated dataset types from transfer list", 

1004 collection, 

1005 len(dataset_types), 

1006 ) 

1007 elif curated_types is not None: 1007 ↛ 1008line 1007 didn't jump to line 1008 because the condition on line 1007 was never true

1008 dataset_types = list(curated_types) 

1009 except Exception as e: 

1010 logger.warning("Failed to query get_info for collection '%s': %s", collection, e) 

1011 dataset_types = list(curated_types) if curated_types is not None else [] 

1012 

1013 dataset_types = _limit_dataset_types(dataset_types, max_dataset_types) 

1014 logger.info(f"Collection '{collection}' has {len(dataset_types)} dataset types to process") 

1015 

1016 for q_idx in range(len(covered_quarters) - 1): 1016 ↛ 967line 1016 didn't jump to line 967 because the loop on line 1016 didn't complete

1017 q_start = covered_quarters[q_idx] 

1018 q_end = covered_quarters[q_idx + 1] 

1019 

1020 if q_start >= cutoff_date: 

1021 logger.info( 

1022 f"Skipping quarter {q_start} to {q_end} as start is past cutoff_date {cutoff_date}" 

1023 ) 

1024 break 

1025 

1026 q_num = (int(q_start.split("-")[1]) - 1) // 3 + 1 

1027 year_val = q_start.split("-")[0] 

1028 effective_start = max(q_start, start_date) 

1029 effective_end = min(q_end, cutoff_date) 

1030 if effective_start >= effective_end: 1030 ↛ 1031line 1030 didn't jump to line 1031 because the condition on line 1030 was never true

1031 continue 

1032 where_expr = f"ingest_date >= T'{effective_start}' AND ingest_date < T'{effective_end}'" 

1033 

1034 # Parallelize Butler queries across dataset types 

1035 tasks = [ 

1036 { 

1037 "collection": collection, 

1038 "dataset_type": ds_type, 

1039 "where": where_expr, 

1040 "curated_types": curated_types, 

1041 } 

1042 for ds_type in dataset_types 

1043 ] 

1044 

1045 logger.info( 

1046 f"Starting parallel Butler query tasks for quarter {q_start} to {q_end} " 

1047 f"({len(tasks)} dataset types, max_workers={max_workers})" 

1048 ) 

1049 

1050 # Query Butler across dataset types using ProcessPoolExecutor 

1051 query_results = [] 

1052 mp_context = multiprocessing.get_context("spawn") 

1053 with ProcessPoolExecutor( 

1054 max_workers=max_workers, 

1055 mp_context=mp_context, 

1056 initializer=_set_log_level, 

1057 initargs=(log_level,), 

1058 ) as executor: 

1059 futures = {executor.submit(_query_butler_task, repo, task): task for task in tasks} 

1060 for future in as_completed(futures): 

1061 t, refs = future.result() 

1062 if refs: 

1063 query_results.append((t["dataset_type"], refs)) 

1064 

1065 total_refs_quarter = sum(len(refs) for _, refs in query_results) 

1066 logger.info( 

1067 f"Completed parallel Butler queries for quarter {q_start} to {q_end}: " 

1068 f"found {total_refs_quarter} total refs across {len(query_results)} active dataset types" 

1069 ) 

1070 

1071 if total_refs_quarter == 0: 

1072 continue 

1073 

1074 # Parallelize registration batches using ProcessPoolExecutor 

1075 batch_tasks = [] 

1076 for ds_type, refs in query_results: 

1077 if len(refs) > split_size: 1077 ↛ 1078line 1077 didn't jump to line 1078 because the condition on line 1077 was never true

1078 for sub_idx, sub_chunk in enumerate(chunks(refs, split_size), 1): 

1079 prefix = dataset_name_prefix or f"Dataset/{collection}" 

1080 sub_ds_name = f"{prefix}-{ds_type}-{df_name}-{year_val}Q{q_num}-{sub_idx:08d}" 

1081 batch_tasks.append( 

1082 { 

1083 "refs": list(sub_chunk), 

1084 "ds_name": sub_ds_name, 

1085 "repo": repo, 

1086 "rucio_register_config": rucio_register_config, 

1087 "dry_run": dry_run, 

1088 "out_dir": out_dir, 

1089 "chunk_size": chunk_size, 

1090 "max_retries": max_retries, 

1091 "backoff_factor": backoff_factor, 

1092 "backoff_max_value": backoff_max_value, 

1093 "clear_is_new": clear_is_new, 

1094 } 

1095 ) 

1096 else: 

1097 prefix = dataset_name_prefix or f"Dataset/{collection}" 

1098 ds_name = f"{prefix}-{ds_type}-{df_name}-{year_val}Q{q_num}-00000001" 

1099 batch_tasks.append( 

1100 { 

1101 "refs": refs, 

1102 "ds_name": ds_name, 

1103 "repo": repo, 

1104 "rucio_register_config": rucio_register_config, 

1105 "dry_run": dry_run, 

1106 "out_dir": out_dir, 

1107 "chunk_size": chunk_size, 

1108 "max_retries": max_retries, 

1109 "backoff_factor": backoff_factor, 

1110 "backoff_max_value": backoff_max_value, 

1111 "clear_is_new": clear_is_new, 

1112 } 

1113 ) 

1114 

1115 logger.info( 

1116 f"Starting parallel registration workers for quarter {q_start} to {q_end} " 

1117 f"({len(batch_tasks)} sub-batches, batch_workers={max_workers})" 

1118 ) 

1119 

1120 with ProcessPoolExecutor( 

1121 max_workers=max_workers, 

1122 mp_context=mp_context, 

1123 initializer=_set_log_level, 

1124 initargs=(log_level,), 

1125 ) as executor: 

1126 futures = { 

1127 executor.submit(_process_auto_register_batch, **task_kwargs): task_kwargs 

1128 for task_kwargs in batch_tasks 

1129 } 

1130 for future in as_completed(futures): 

1131 try: 

1132 res = future.result() 

1133 global_stats["registered"] += res.get("registered", 0) 

1134 global_stats["failed"] += res.get("failed", 0) 

1135 if "failures" in res and res["failures"]: 1135 ↛ 1136line 1135 didn't jump to line 1136 because the condition on line 1135 was never true

1136 global_stats["failures"].extend(res["failures"]) 

1137 except Exception as e: 

1138 logger.error(f"Auto-register batch worker failed: {e}") 

1139 

1140 elapsed_time = time.time() - start_time 

1141 logger.info("Auto-register processing completed in %.3f seconds", elapsed_time) 

1142 logger.info( 

1143 f"Auto-register total summary - registered: {global_stats['registered']}, " 

1144 f"failed: {global_stats['failed']}" 

1145 ) 

1146 if global_stats["failed"] > 0: 1146 ↛ 1147line 1146 didn't jump to line 1147 because the condition on line 1146 was never true

1147 logger.error(f"Auto-register failed dataset UUIDs: {global_stats['failures']}")