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

555 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-03 09:42 +0000

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 os 

28import sys 

29import threading 

30import time 

31import urllib.request 

32from concurrent.futures import ProcessPoolExecutor, as_completed 

33from typing import Any 

34 

35import click 

36import yaml 

37 

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

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

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

41from lsst.resources import ResourcePath 

42from lsst.rucio.register.data_type import DataType 

43from lsst.rucio.register.rucio_interface import RucioInterface 

44from lsst.rucio.register.rucio_register_config import RucioRegisterConfig 

45 

46_thread_local = threading.local() 

47 

48logger = logging.getLogger(__name__) 

49_FORMAT = ( 

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

51) 

52 

53RUCIO_REGISTER_CONFIG = "RUCIO_REGISTER_CONFIG" 

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

55 

56 

57def register_options(func): 

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

59 func = click.option( 

60 "--backoff-max-tries", 

61 "--max-retries", 

62 "max_retries", 

63 required=False, 

64 type=int, 

65 default=RucioInterface.DEFAULT_RETRIES, 

66 show_default=True, 

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

68 )(func) 

69 func = click.option( 

70 "--backoff-max-value", 

71 required=False, 

72 type=int, 

73 default=RucioInterface.DEFAULT_BACKOFF_MAX_VALUE, 

74 show_default=True, 

75 help="maximum backoff value in seconds", 

76 )(func) 

77 func = click.option( 

78 "--backoff-factor", 

79 required=False, 

80 type=float, 

81 default=RucioInterface.DEFAULT_BACKOFF_FACTOR, 

82 show_default=True, 

83 help="multiplicative factor to backoff by", 

84 )(func) 

85 func = click.option( 

86 "--chunk-size", 

87 required=False, 

88 type=int, 

89 default=500, 

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

91 )(func) 

92 func = click.option( 

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

94 )(func) 

95 func = click.option( 

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

97 )(func) 

98 return func 

99 

100 

101def chunks(refs, chunk_size): 

102 it = iter(refs) 

103 while True: 

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

105 if not chunk: 

106 return 

107 yield chunk 

108 

109 

110_rucio_interface_cache = {} 

111 

112 

113def _get_rucio_interface( 

114 repo, 

115 rucio_register_config, 

116 rubin_butler_type, 

117 clear_is_new=False, 

118 retries=RucioInterface.DEFAULT_RETRIES, 

119 kwargs=None, 

120): 

121 if kwargs: 

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

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

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

125 if backoff_max_tries is not None: 

126 retries = backoff_max_tries 

127 else: 

128 backoff_factor = RucioInterface.DEFAULT_BACKOFF_FACTOR 

129 backoff_max_value = RucioInterface.DEFAULT_BACKOFF_MAX_VALUE 

130 backoff_max_tries = retries 

131 

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

133 cache_key = ( 

134 repo, 

135 config_file, 

136 rubin_butler_type, 

137 clear_is_new, 

138 retries, 

139 backoff_factor, 

140 backoff_max_value, 

141 ) 

142 if cache_key in _rucio_interface_cache: 

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

144 return _rucio_interface_cache[cache_key] 

145 

146 if config_file is None: 

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

148 

149 config = RucioRegisterConfig(config_file) 

150 

151 rucio_rse = config.rucio_rse 

152 scope = config.scope 

153 rse_root = config.rse_root 

154 dtn_url = config.dtn_url 

155 

156 butler = None 

157 if repo: 

158 butler = Butler(repo) 

159 

160 # create RucioInterface object used to register replicas into datasets 

161 ri = RucioInterface( 

162 butler=butler, 

163 rucio_rse=rucio_rse, 

164 scope=scope, 

165 rse_root=rse_root, 

166 dtn_url=dtn_url, 

167 rubin_butler_type=rubin_butler_type, 

168 clear_is_new=clear_is_new, 

169 retries=retries, 

170 ) 

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

172 

173 _rucio_interface_cache[cache_key] = (ri, butler) 

174 return ri, butler 

175 

176 

177def worker_register_replicas( 

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

179): 

180 ri, _ = _get_rucio_interface( 

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

182 ) 

183 return ri.register_as_replicas(rucio_dataset, refs_list) 

184 

185 

186def worker_register_zips( 

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

188): 

189 ri, _ = _get_rucio_interface( 

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

191 ) 

192 return ri.register_zips(rucio_dataset, zip_files) 

193 

194 

195def worker_register_dims( 

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

197): 

198 ri, _ = _get_rucio_interface( 

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

200 ) 

201 return ri.register_dims(rucio_dataset, dim_files) 

202 

203 

204def _register( 

205 repo, 

206 rucio_register_config, 

207 rubin_butler_type, 

208 dataset_refs, 

209 chunk_size, 

210 rucio_dataset, 

211 max_workers=1, 

212 retries=RucioInterface.DEFAULT_RETRIES, 

213 stats=None, 

214 clear_is_new=False, 

215 kwargs=None, 

216): 

217 failed_uuids = [] 

218 registered_count = 0 

219 

220 total_refs = None 

221 try: 

222 total_refs = len(dataset_refs) 

223 except TypeError: 

224 pass 

225 

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

227 logger.info( 

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

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

230 ) 

231 else: 

232 logger.info( 

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

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

235 ) 

236 

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

238 total_chunks = len(ref_chunks) 

239 

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

241 with ProcessPoolExecutor(max_workers=max_workers) as executor: 

242 future_to_chunk = { 

243 executor.submit( 

244 worker_register_replicas, 

245 repo, 

246 rucio_register_config, 

247 rubin_butler_type, 

248 rucio_dataset, 

249 refs, 

250 retries, 

251 clear_is_new, 

252 kwargs, 

253 ): (i, refs) 

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

255 } 

256 

257 for future in as_completed(future_to_chunk): 

258 chunk_idx, refs = future_to_chunk[future] 

259 try: 

260 count = future.result() 

261 registered_count += count 

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

263 except Exception as e: 

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

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

266 failed_uuids.extend(chunk_uuids) 

267 else: 

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

269 refs_list = list(refs) 

270 try: 

271 count = worker_register_replicas( 

272 repo, 

273 rucio_register_config, 

274 rubin_butler_type, 

275 rucio_dataset, 

276 refs_list, 

277 retries, 

278 clear_is_new, 

279 kwargs, 

280 ) 

281 registered_count += count 

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

283 except Exception as e: 

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

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

286 failed_uuids.extend(chunk_uuids) 

287 

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

289 stats["registered"] = registered_count 

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

291 stats["failures"] = failed_uuids 

292 

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

294 logger.error( 

295 f"Registration summary for {rucio_dataset} - " 

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

297 ) 

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

299 else: 

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

301 

302 

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

304 if isinstance(log_level, dict): 

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

306 if isinstance(log_level, str): 

307 handler = logging.StreamHandler(sys.stdout) 

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

309 log = logging.getLogger() 

310 log.handlers.clear() 

311 log.addHandler(handler) 

312 log.setLevel(log_level.upper()) 

313 

314 

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

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

317 if max_dataset_types is None: 

318 return dataset_types 

319 return list(dataset_types[:max_dataset_types]) 

320 

321 

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

323def main(): 

324 pass 

325 

326 

327@main.command() 

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

329@register_options 

330@click.option( 

331 "--max-workers", 

332 required=False, 

333 type=int, 

334 default=5, 

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

336) 

337@click.option( 

338 "--clear-is-new", 

339 is_flag=True, 

340 default=False, 

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

342) 

343@log_level_option() 

344@options_file_option() 

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

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

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

348 _set_log_level(log_level) 

349 

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

351 if not rucio_dataset: 

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

353 

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

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

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

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

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

359 

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

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

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

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

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

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

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

367 

368 ri, butler = _get_rucio_interface( 

369 repo, 

370 rucio_register_config, 

371 DataType.DATA_PRODUCT, 

372 clear_is_new=clear_is_new, 

373 retries=max_retries, 

374 kwargs=kwargs, 

375 ) 

376 

377 query = QueryDatasets( 

378 butler=butler, 

379 glob=dataset_type, 

380 collections=collections, 

381 where=where, 

382 find_first=find_first, 

383 limit=limit, 

384 order_by=order_by, 

385 show_uri=False, 

386 with_dimension_records=True, 

387 ) 

388 

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

390 

391 stats = {} 

392 _register( 

393 repo, 

394 rucio_register_config, 

395 DataType.DATA_PRODUCT, 

396 dataset_refs, 

397 chunk_size, 

398 rucio_dataset, 

399 max_workers=max_workers, 

400 retries=max_retries, 

401 stats=stats, 

402 clear_is_new=clear_is_new, 

403 kwargs=kwargs, 

404 ) 

405 

406 

407@main.command() 

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

409@register_options 

410@click.option( 

411 "--max-workers", 

412 required=False, 

413 type=int, 

414 default=5, 

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

416) 

417@click.option( 

418 "--uuidlist", 

419 required=False, 

420 type=str, 

421 help=( 

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

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

424 ), 

425) 

426@log_level_option() 

427@options_file_option() 

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

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

430 _set_log_level(log_level) 

431 

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

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

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

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

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

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

438 

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

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

441 

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

443 

444 # Infer dataset from file header if available 

445 uuids = [] 

446 inferred_dataset = None 

447 with open(uuidlist) as f: 

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

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

450 try: 

451 data = json.loads(content) 

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

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

454 elif isinstance(data, dict): 

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

456 except json.JSONDecodeError: 

457 pass 

458 

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

460 for line in content.splitlines(): 

461 stripped = line.strip() 

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

463 parts = stripped.split() 

464 if len(parts) >= 2: 

465 inferred_dataset = parts[1] 

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

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

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

469 uuids.append(clean_uuid) 

470 

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

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

473 raise click.UsageError( 

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

475 ) 

476 

477 ri, butler = _get_rucio_interface( 

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

479 ) 

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

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

482 

483 dataset_refs = butler.get_many_datasets(uuids) 

484 

485 stats = {} 

486 _register( 

487 repo, 

488 rucio_register_config, 

489 DataType.DATA_PRODUCT, 

490 dataset_refs, 

491 chunk_size, 

492 rucio_dataset, 

493 max_workers=max_workers, 

494 retries=max_retries, 

495 stats=stats, 

496 kwargs=kwargs, 

497 ) 

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

499 

500 

501@main.command() 

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

503@register_options 

504@click.option( 

505 "--max-workers", 

506 required=False, 

507 type=int, 

508 default=5, 

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

510) 

511@log_level_option() 

512@options_file_option() 

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

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

515 log_level = kwargs.pop("log_level") 

516 _set_log_level(log_level) 

517 

518 rucio_dataset = kwargs.get("rucio_dataset") 

519 if not rucio_dataset: 

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

521 

522 rucio_register_config = kwargs.get("rucio_register_config") 

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

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

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

526 

527 repo = kwargs["repo"] 

528 

529 ri, butler = _get_rucio_interface( 

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

531 ) 

532 

533 query_kwargs = { 

534 k: v 

535 for k, v in kwargs.items() 

536 if k 

537 not in ( 

538 "backoff_factor", 

539 "backoff_max_value", 

540 "backoff_max_tries", 

541 "max_retries", 

542 "backoff-factor", 

543 "backoff-max-value", 

544 "backoff-max-tries", 

545 "max_workers", 

546 "rucio_dataset", 

547 "rucio_register_config", 

548 "chunk_size", 

549 ) 

550 } 

551 

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

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

554 

555 stats = {} 

556 _register( 

557 repo, 

558 rucio_register_config, 

559 DataType.RAW_FILE, 

560 dataset_refs, 

561 chunk_size, 

562 rucio_dataset, 

563 max_workers=max_workers, 

564 retries=max_retries, 

565 stats=stats, 

566 kwargs=kwargs, 

567 ) 

568 

569 

570@main.command() 

571@register_options 

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

573@log_level_option() 

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

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

576 _set_log_level(log_level) 

577 

578 rucio_dataset = kwargs.get("rucio_dataset") 

579 if not rucio_dataset: 

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

581 

582 zip_file = kwargs.get("zip_file") 

583 rucio_register_config = kwargs.get("rucio_register_config") 

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

585 

586 ri, butler = _get_rucio_interface( 

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

588 ) 

589 

590 try: 

591 count = worker_register_zips( 

592 repo=None, 

593 rucio_config=rucio_register_config, 

594 butler_type=DataType.ZIP_FILE, 

595 rucio_dataset=rucio_dataset, 

596 zip_files=[ResourcePath(zip_file)], 

597 retries=max_retries, 

598 kwargs=kwargs, 

599 ) 

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

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

602 except Exception as e: 

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

604 

605 

606@main.command() 

607@register_options 

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

609@log_level_option() 

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

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

612 _set_log_level(log_level) 

613 

614 rucio_dataset = kwargs.get("rucio_dataset") 

615 if not rucio_dataset: 

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

617 

618 dim_file = kwargs.get("dimension_file") 

619 rucio_register_config = kwargs.get("rucio_register_config") 

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

621 

622 ri, butler = _get_rucio_interface( 

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

624 ) 

625 

626 try: 

627 count = worker_register_dims( 

628 repo=None, 

629 rucio_config=rucio_register_config, 

630 butler_type=DataType.DIM_FILE, 

631 rucio_dataset=rucio_dataset, 

632 dim_files=[ResourcePath(dim_file)], 

633 retries=max_retries, 

634 kwargs=kwargs, 

635 ) 

636 logger.info( 

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

638 ) 

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

640 except Exception as e: 

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

642 

643 

644def _process_refs_chunk( 

645 refs_chunk, 

646 rucio_dataset_name, 

647 dry_run, 

648 out_dir, 

649 repo, 

650 rucio_register_config, 

651 chunk_size, 

652 max_retries, 

653 clear_is_new=False, 

654 kwargs=None, 

655): 

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

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

658 if dry_run: 

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

660 for ref in refs_chunk: 

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

662 

663 # Sanitize repo path for use in output directory structure 

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

665 # Sanitize rucio_dataset_name for use as a filename 

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

667 

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

669 os.makedirs(uuiddir, exist_ok=True) 

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

671 

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

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

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

675 else: 

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

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

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

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

680 # This is now the default for _register. 

681 _register( 

682 repo, 

683 rucio_register_config, 

684 DataType.DATA_PRODUCT, 

685 refs_chunk, 

686 chunk_size, 

687 rucio_dataset_name, 

688 retries=max_retries, 

689 stats=local_stats, 

690 clear_is_new=clear_is_new, 

691 kwargs=kwargs, 

692 ) 

693 return local_stats 

694 

695 

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

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

698 auto-registration. 

699 """ 

700 local_stats = {"registered": 0, "failed": 0} 

701 

702 # If pre-queried refs are passed directly: 

703 refs = kwargs.get("refs") 

704 ds_name = kwargs.get("ds_name") 

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

706 if refs is not None: 706 ↛ 731line 706 didn't jump to line 731 because the condition on line 706 was always true

707 repo = kwargs.get("repo") 

708 rucio_register_config = kwargs.get("rucio_register_config") 

709 dry_run = kwargs.get("dry_run") 

710 out_dir = kwargs.get("out_dir") 

711 chunk_size = kwargs.get("chunk_size") 

712 max_retries = kwargs.get("max_retries") 

713 logger.info( 

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

715 len(refs), 

716 ds_name, 

717 ) 

718 return _process_refs_chunk( 

719 refs, 

720 ds_name, 

721 dry_run, 

722 out_dir, 

723 repo, 

724 rucio_register_config, 

725 chunk_size, 

726 max_retries, 

727 clear_is_new=clear_is_new, 

728 kwargs=kwargs, 

729 ) 

730 

731 return local_stats 

732 

733 

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

735 content = "" 

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

737 try: 

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

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

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

741 except Exception as e: 

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

743 raise 

744 else: 

745 try: 

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

747 with open(transfer_list) as f: 

748 content = f.read() 

749 except Exception as e: 

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

751 raise 

752 

753 try: 

754 data = yaml.safe_load(content) 

755 except Exception as e: 

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

757 raise 

758 

759 curated_types = set() 

760 

761 def _traverse(node): 

762 if isinstance(node, list): 

763 for item in node: 

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

765 curated_types.add(item) 

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

767 for val in node.values(): 

768 _traverse(val) 

769 

770 _traverse(data) 

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

772 return curated_types 

773 

774 

775def _matches_dataset_type(ref: Any, target_type: str) -> bool: 

776 """Check if a DatasetRef matches a target dataset_type name. 

777 

778 Supports MagicMock. 

779 """ 

780 ds_type = getattr(ref, "dataset_type", getattr(ref, "datasetType", None)) 

781 if ds_type is None: 781 ↛ 782line 781 didn't jump to line 782 because the condition on line 781 was never true

782 return True 

783 type_name = getattr(ds_type, "name", None) if not isinstance(ds_type, str) else ds_type 

784 if type_name is None: 784 ↛ 785line 784 didn't jump to line 785 because the condition on line 784 was never true

785 return True 

786 if type(type_name).__name__ == "MagicMock": 

787 return True 

788 return str(type_name) == str(target_type) 

789 

790 

791def _query_butler_task( 

792 repo: str, task: dict[str, Any], butler: Any = None 

793) -> tuple[dict[str, Any], list[Any]]: 

794 collection = task["collection"] 

795 dataset_type = task["dataset_type"] 

796 where = task["where"] 

797 limit = task.get("limit", None) 

798 curated_types = task.get("curated_types") 

799 

800 if curated_types is not None: 

801 limit = None 

802 

803 try: 

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

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

806 _thread_local.butlers = {} 

807 if repo not in _thread_local.butlers: 

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

809 butler = _thread_local.butlers[repo] 

810 

811 if dataset_type is None: 811 ↛ 812line 811 didn't jump to line 812 because the condition on line 811 was never true

812 refs = list( 

813 butler.query_all_datasets( 

814 collections=collection, 

815 name=task.get("dataset_types", "*"), 

816 where=where, 

817 find_first=False, 

818 limit=limit, 

819 ) 

820 ) 

821 if curated_types is not None: 

822 refs = [ 

823 ref 

824 for ref in refs 

825 if _matches_dataset_type( 

826 ref, getattr(ref, "dataset_type", getattr(ref, "datasetType", None)) 

827 ) 

828 ] 

829 else: 

830 try: 

831 raw_res = butler.query_datasets( 

832 dataset_type=dataset_type, 

833 collections=collection, 

834 find_first=False, 

835 where=where, 

836 order_by="ingest_date", 

837 limit=None, 

838 explain=False, 

839 ) 

840 is_mock = type(raw_res).__name__ == "MagicMock" 

841 refs = list(raw_res) if not is_mock else [] 

842 except _exceptions.EmptyQueryResultError: 

843 is_mock = False 

844 refs = [] 

845 except Exception: 

846 is_mock = True 

847 refs = [] 

848 

849 if (is_mock or not refs) and hasattr(butler, "query_all_datasets"): 

850 if not is_mock and refs == []: 

851 pass 

852 else: 

853 try: 

854 res = butler.query_all_datasets( 

855 collections=collection, 

856 name=dataset_type, 

857 where=where, 

858 find_first=False, 

859 limit=limit, 

860 ) 

861 if isinstance(res, list) and res: 861 ↛ 867line 861 didn't jump to line 867 because the condition on line 861 was always true

862 matching = [r for r in res if _matches_dataset_type(r, dataset_type)] 

863 if matching: 863 ↛ 867line 863 didn't jump to line 867 because the condition on line 863 was always true

864 refs = matching 

865 except Exception: 

866 pass 

867 return task, refs 

868 except _exceptions.EmptyQueryResultError as e: 

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

870 return task, [] 

871 except Exception as e: 

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

873 return task, [] 

874 

875 

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

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

878 date_str = date_str.strip() 

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

880 return date_str 

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

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

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

884 

885 

886@main.command() 

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

888@register_options 

889@click.option( 

890 "--dataset-name-prefix", 

891 required=False, 

892 type=str, 

893 default=None, 

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

895) 

896@click.option( 

897 "--max-workers", 

898 required=False, 

899 type=int, 

900 default=5, 

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

902) 

903@click.option( 

904 "--start-date", 

905 required=False, 

906 default="2000-01-01", 

907 type=str, 

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

909) 

910@click.option( 

911 "--cutoff-date", 

912 required=False, 

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

914 type=str, 

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

916) 

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

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

919@click.option( 

920 "--max-did-per-dataset", 

921 default=50_000, 

922 type=int, 

923 help="Max items per Rucio dataset", 

924) 

925@click.option( 

926 "--max-dataset-types", 

927 default=None, 

928 type=int, 

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

930) 

931@click.option( 

932 "--split-size", 

933 default=2000, 

934 type=int, 

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

936) 

937@click.option( 

938 "--transfer-list", 

939 required=False, 

940 type=str, 

941 default="", 

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

943) 

944@click.option( 

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

946) 

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

948@click.option( 

949 "--clear-is-new", 

950 is_flag=True, 

951 default=False, 

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

953) 

954@log_level_option() 

955@options_file_option() 

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

957 """ 

958 Auto-discovers and registers datasets chronologically by quarters, 

959 dynamically partitioning massive collections into chunked Rucio datasets. 

960 

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

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

963 """ 

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

965 _set_log_level(log_level) 

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

967 

968 repo = kwargs["repo"] 

969 rucio_register_config = kwargs.get("rucio_register_config") 

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

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

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

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

974 

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

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

977 root_chain = kwargs["root_chain"] 

978 df_name = kwargs["df_name"] 

979 max_dataset_types = kwargs.get("max_dataset_types") 

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

981 dry_run = kwargs["dry_run"] 

982 out_dir = kwargs["out_dir"] 

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

984 

985 butler = Butler(repo) 

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

987 curated_types = None 

988 if transfer_list: 

989 curated_types = _load_curated_dataset_types(transfer_list) 

990 start_time = time.time() 

991 

992 # Aggregate at end to avoid thread race conditions 

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

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

995 

996 # Calculate covered quarters 

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

998 quarters = [] 

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

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

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

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

1003 

1004 q_skip = 0 

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

1006 if quarters[i + 1] > start_date: 1006 ↛ 1005line 1006 didn't jump to line 1005 because the condition on line 1006 was always true

1007 q_skip = i 

1008 break 

1009 

1010 covered_quarters = quarters[q_skip:] 

1011 

1012 # Discover all collections matching root-chain glob 

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

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

1015 

1016 for collection in matched_collections: 

1017 logger.info( 

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

1019 ) 

1020 

1021 dataset_types = [] 

1022 try: 

1023 try: 

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

1025 except TypeError: 

1026 info = butler.collections.get_info(collection) 

1027 if info is not None: 

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

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

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

1031 

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

1033 discovered_types = [ 

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

1035 ] 

1036 if curated_types is not None: 

1037 curated_set = set(curated_types) 

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

1039 logger.info( 

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

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

1042 len(discovered_types), 

1043 len(dataset_types), 

1044 collection, 

1045 ) 

1046 else: 

1047 dataset_types = discovered_types 

1048 elif curated_types is not None: 

1049 dataset_types = list(curated_types) 

1050 logger.info( 

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

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

1053 collection, 

1054 len(dataset_types), 

1055 ) 

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

1057 dataset_types = list(curated_types) 

1058 except Exception as e: 

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

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

1061 

1062 dataset_types = _limit_dataset_types(dataset_types, max_dataset_types) 

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

1064 

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

1066 q_start = covered_quarters[q_idx] 

1067 q_end = covered_quarters[q_idx + 1] 

1068 

1069 if q_start >= cutoff_date: 

1070 logger.info( 

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

1072 ) 

1073 break 

1074 

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

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

1077 where_expr = f"ingest_date >= T'{q_start}' AND ingest_date < T'{q_end}'" 

1078 

1079 # Parallelize Butler queries across dataset types 

1080 tasks = [ 

1081 { 

1082 "collection": collection, 

1083 "dataset_type": ds_type, 

1084 "where": where_expr, 

1085 "curated_types": curated_types, 

1086 } 

1087 for ds_type in dataset_types 

1088 ] 

1089 

1090 logger.info( 

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

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

1093 ) 

1094 

1095 # Query Butler across dataset types using ProcessPoolExecutor 

1096 query_results = [] 

1097 with ProcessPoolExecutor(max_workers=max_workers) as executor: 

1098 b_arg = butler if type(executor).__name__ == "DummyExecutor" else None 

1099 futures = { 

1100 executor.submit(_query_butler_task, repo, task, butler=b_arg): task for task in tasks 

1101 } 

1102 for future in as_completed(futures): 

1103 t, refs = future.result() 

1104 if refs: 

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

1106 

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

1108 logger.info( 

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

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

1111 ) 

1112 

1113 if total_refs_quarter == 0: 

1114 continue 

1115 

1116 # Parallelize registration batches using ProcessPoolExecutor 

1117 batch_tasks = [] 

1118 for ds_type, refs in query_results: 

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

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

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

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

1123 batch_tasks.append( 

1124 { 

1125 "refs": list(sub_chunk), 

1126 "ds_name": sub_ds_name, 

1127 "repo": repo, 

1128 "rucio_register_config": rucio_register_config, 

1129 "dry_run": dry_run, 

1130 "out_dir": out_dir, 

1131 "chunk_size": chunk_size, 

1132 "max_retries": max_retries, 

1133 "clear_is_new": clear_is_new, 

1134 } 

1135 ) 

1136 else: 

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

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

1139 batch_tasks.append( 

1140 { 

1141 "refs": refs, 

1142 "ds_name": ds_name, 

1143 "repo": repo, 

1144 "rucio_register_config": rucio_register_config, 

1145 "dry_run": dry_run, 

1146 "out_dir": out_dir, 

1147 "chunk_size": chunk_size, 

1148 "max_retries": max_retries, 

1149 "clear_is_new": clear_is_new, 

1150 } 

1151 ) 

1152 

1153 logger.info( 

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

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

1156 ) 

1157 

1158 with ProcessPoolExecutor(max_workers=max_workers) as executor: 

1159 futures = { 

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

1161 for task_kwargs in batch_tasks 

1162 } 

1163 for future in as_completed(futures): 

1164 try: 

1165 res = future.result() 

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

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

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

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

1170 except Exception as e: 

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

1172 

1173 elapsed_time = time.time() - start_time 

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

1175 logger.info( 

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

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

1178 ) 

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

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