File indexing completed on 2026-09-01 09:33:40
0001 """
0002 Async request handlers for Data Carousel operations, registered into processor.HANDLERS.
0003
0004 The operations themselves live in pandaserver.taskbuffer.data_carousel_ops and are shared
0005 with the synchronous endpoints of pandaserver.api.v1.data_carousel_api. These handlers only
0006 translate between the async request framework and those operations.
0007
0008 Data Carousel operations mutate DDM rules and Data Carousel requests, so they must never be
0009 run twice for a single request: a failed operation is reported as a terminal "done" result
0010 holding success=False, and an unexpected exception is written as a non-retriable "failed"
0011 result. While an operation is in flight a heartbeat keeps its result row fresh so
0012 recover_stale_results doesn't hand it to another machine.
0013 """
0014
0015 import functools
0016 import json
0017 import threading
0018 import traceback
0019
0020 from pandaserver.taskbuffer.db_proxy_mods.async_request_module import (
0021 PARAMETER_META_KEYS,
0022 )
0023
0024
0025 _HEARTBEAT_INTERVAL_SECONDS = 60
0026
0027 _dcif = None
0028 _dcif_lock = threading.Lock()
0029
0030
0031 def _get_dcif(tb):
0032 """
0033 Get the DataCarouselInterface, creating it on first use.
0034
0035 The import and the constructor are expensive (Rucio, iDDS and polars imports, RSE listing
0036 and config loading), so a daemon that never gets a Data Carousel request never pays for it.
0037
0038 Only the task buffer is passed: the constructor sets its own ddmIF, so the second argument
0039 other JEDI callers pass would be ignored anyway.
0040
0041 Args:
0042 tb(TaskBuffer): task buffer to build the interface on; the JEDI taskBufferIF when the
0043 operations run under pandajedi.jedidog.AsyncRequestWatchDog
0044
0045 Returns:
0046 DataCarouselInterface: shared interface instance
0047 """
0048 global _dcif
0049 with _dcif_lock:
0050 if _dcif is None:
0051 from pandaserver.taskbuffer.DataCarousel import DataCarouselInterface
0052
0053 _dcif = DataCarouselInterface(tb)
0054 return _dcif
0055
0056
0057 class _ResultHeartbeat:
0058 """
0059 Context manager refreshing started_at of a running result row until the operation finishes.
0060
0061 processor.run resets rows that have been running for longer than its stale threshold back to
0062 pending, which for a mutating operation would mean executing it a second time on another
0063 machine. Refreshing started_at keeps a legitimately slow operation from looking stale.
0064 """
0065
0066 def __init__(self, tb, request_id, machine_name, tmp_logger):
0067 self._tb = tb
0068 self._request_id = request_id
0069 self._machine_name = machine_name
0070 self._tmp_logger = tmp_logger
0071 self._stop_event = threading.Event()
0072 self._thread = None
0073
0074 def _beat(self):
0075 while not self._stop_event.wait(_HEARTBEAT_INTERVAL_SECONDS):
0076 try:
0077 if not self._tb.touch_async_result(self._request_id, self._machine_name):
0078
0079
0080 self._tmp_logger.warning(f"heartbeat did not refresh the result row of machine={self._machine_name}; the claim may have been lost")
0081 except Exception as e:
0082 self._tmp_logger.warning(f"heartbeat failed with {e}")
0083
0084 def __enter__(self):
0085 self._thread = threading.Thread(target=self._beat, daemon=True)
0086 self._thread.start()
0087 return self
0088
0089 def __exit__(self, exc_type, exc_value, exc_traceback):
0090 self._stop_event.set()
0091 self._thread.join(timeout=5)
0092 return False
0093
0094
0095 def _handle(operation_name, row, tb, tmp_logger, result_machine):
0096 """
0097 Run one Data Carousel operation and store its outcome as the request's result.
0098
0099 Args:
0100 operation_name(str): key in data_carousel_ops.OPERATIONS
0101 row(dict): the async_requests row to process
0102 tb(TaskBuffer): task buffer
0103 tmp_logger(LogWrapper): logger of the processing cycle
0104 result_machine(str): machine_name the result row is keyed by
0105 """
0106 from pandaserver.taskbuffer import data_carousel_ops
0107
0108 request_id = row["request_id"]
0109 try:
0110 operation = data_carousel_ops.OPERATIONS[operation_name]
0111 parameters = json.loads(row["parameters"] or "{}")
0112 kwargs = {key: value for key, value in parameters.items() if key not in PARAMETER_META_KEYS}
0113 tmp_logger.debug(f"running {operation_name} with {kwargs}")
0114 with _ResultHeartbeat(tb, request_id, result_machine, tmp_logger):
0115 success, message, data = operation(_get_dcif(tb), **kwargs)
0116 except Exception:
0117
0118 err_msg = traceback.format_exc()
0119 tmp_logger.error(f"failed to run {operation_name} with {err_msg}")
0120 tb.finish_async_result(request_id, result_machine, "failed", error_msg=err_msg, retriable=False)
0121 return
0122
0123
0124 tmp_logger.debug(f"{operation_name} returned success={success} message={message}")
0125 tb.finish_async_result(
0126 request_id,
0127 result_machine,
0128 "done",
0129 result=json.dumps({"success": success, "message": message, "data": data}),
0130 )
0131
0132
0133
0134
0135 _OPERATION_NAMES = (
0136 "change_staging_destination",
0137 "change_staging_source",
0138 "force_to_staging",
0139 "retire_unused",
0140 )
0141
0142
0143 HANDLERS = {f"dc_{operation_name}": functools.partial(_handle, operation_name) for operation_name in _OPERATION_NAMES}