Coverage for python/lsst/rucio/register/script.py: 64%
555 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-03 01:33 -0800
« prev ^ index » next coverage.py v7.16.2, created at 2026-11-03 01:33 -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/>.
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
35import click
36import yaml
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
46_thread_local = threading.local()
48logger = logging.getLogger(__name__)
49_FORMAT = (
50 "%(levelname) -10s %(asctime)s.%(msecs)03dZ %(name) -30s %(funcName) -35s %(lineno) -5d: %(message)s"
51)
53RUCIO_REGISTER_CONFIG = "RUCIO_REGISTER_CONFIG"
54_MSG = "environment variable not set, and no configuration was specified on the command line"
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
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
110_rucio_interface_cache = {}
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
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]
146 if config_file is None:
147 raise RuntimeError(f"{RUCIO_REGISTER_CONFIG} {_MSG}")
149 config = RucioRegisterConfig(config_file)
151 rucio_rse = config.rucio_rse
152 scope = config.scope
153 rse_root = config.rse_root
154 dtn_url = config.dtn_url
156 butler = None
157 if repo:
158 butler = Butler(repo)
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)
173 _rucio_interface_cache[cache_key] = (ri, butler)
174 return ri, butler
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)
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)
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)
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
220 total_refs = None
221 try:
222 total_refs = len(dataset_refs)
223 except TypeError:
224 pass
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 )
237 ref_chunks = list(chunks(dataset_refs, chunk_size))
238 total_chunks = len(ref_chunks)
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 }
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)
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
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")
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())
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])
322@click.group(context_settings={"help_option_names": ["-h", "--help"]})
323def main():
324 pass
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)
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.")
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)
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)
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 )
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 )
389 dataset_refs = itertools.chain(*query.getDatasets())
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 )
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)
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.")
439 logger.info(f"Reading UUIDs from {uuidlist}")
440 max_retries = kwargs.get("max_retries", kwargs.get("backoff_max_tries", RucioInterface.DEFAULT_RETRIES))
442 repo = kwargs.get("repo", None)
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
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)
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 )
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.")
483 dataset_refs = butler.get_many_datasets(uuids)
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}")
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)
518 rucio_dataset = kwargs.get("rucio_dataset")
519 if not rucio_dataset:
520 raise click.UsageError("--rucio-dataset must be provided for raws.")
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))
527 repo = kwargs["repo"]
529 ri, butler = _get_rucio_interface(
530 repo, rucio_register_config, DataType.RAW_FILE, retries=max_retries, kwargs=kwargs
531 )
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 }
552 # chain is needed to flatten the list of lists returned by getDatasets()
553 dataset_refs = itertools.chain.from_iterable(QueryDatasets(**query_kwargs).getDatasets())
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 )
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)
578 rucio_dataset = kwargs.get("rucio_dataset")
579 if not rucio_dataset:
580 raise click.UsageError("--rucio-dataset must be provided for zips.")
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))
586 ri, butler = _get_rucio_interface(
587 None, rucio_register_config, DataType.ZIP_FILE, retries=max_retries, kwargs=kwargs
588 )
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)
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)
614 rucio_dataset = kwargs.get("rucio_dataset")
615 if not rucio_dataset:
616 raise click.UsageError("--rucio-dataset must be provided for dimensions.")
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))
622 ri, butler = _get_rucio_interface(
623 None, rucio_register_config, DataType.DIM_FILE, retries=max_retries, kwargs=kwargs
624 )
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)
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")
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"
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)
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
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}
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 )
731 return local_stats
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
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
759 curated_types = set()
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)
770 _traverse(data)
771 logger.info("Loaded %d curated dataset types from transfer list", len(curated_types))
772 return curated_types
775def _matches_dataset_type(ref: Any, target_type: str) -> bool:
776 """Check if a DatasetRef matches a target dataset_type name.
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)
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")
800 if curated_types is not None:
801 limit = None
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]
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 = []
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, []
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"
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.
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)
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)
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)
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()
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)
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")
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
1010 covered_quarters = quarters[q_skip:]
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}'")
1016 for collection in matched_collections:
1017 logger.info(
1018 "Processing collection '%s' across %d time quarters", collection, len(covered_quarters) - 1
1019 )
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)
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 []
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")
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]
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
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}'"
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 ]
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 )
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))
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 )
1113 if total_refs_quarter == 0:
1114 continue
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 )
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 )
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}")
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']}")