Skip to content

Commit c6942cc

Browse files
authored
Merge pull request #211 from PanDAWMS/next
3.14.0.22
2 parents 404abd8 + 30a0aba commit c6942cc

39 files changed

Lines changed: 3656 additions & 274 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ build
1313
rucio_upload.json
1414
tools
1515
doc/_build/
16+
__pycache__/
17+
.coverage
18+

PILOTVERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.13.3.3
1+
3.14.0.22

pilot/common/errorcodes.py

Lines changed: 38 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ class ErrorCodes:
191191
NOJOBSINPANDA = 1385 # internally used code
192192
PANDAQUEUENOTONLINE = 1386
193193
ALLOCATIONERROR = 1387
194+
XRDACCESSRESTRICTED = 1388 # XRootD [3010] FullyRestricted / proxy scope too narrow
194195

195196
_error_messages = {
196197
GENERALERROR: "General pilot error, consult batch log",
@@ -286,7 +287,7 @@ class ErrorCodes:
286287
PAYLOADSIGSEGV: "SIGSEGV: Invalid memory reference or a segmentation fault",
287288
NONDETERMINISTICDDM: "Failed to construct SURL for non-deterministic ddm (update CRIC)",
288289
JSONRETRIEVALTIMEOUT: "JSON retrieval timed out",
289-
MISSINGINPUTFILE: "Input file is missing in storage element",
290+
MISSINGINPUTFILE: "Input file missing on storage",
290291
BLACKHOLE: "Black hole detected in file system (consult Pilot log)",
291292
NOREMOTESPACE: "No space left on device",
292293
SETUPFATAL: "Setup failed with a fatal exception (consult Payload log)",
@@ -342,6 +343,7 @@ class ErrorCodes:
342343
NOJOBSINPANDA: "No jobs in PanDA",
343344
PANDAQUEUENOTONLINE: "PanDA queue is not online",
344345
ALLOCATIONERROR: "Failed to allocate memory for transform execution (cling JIT failure)",
346+
XRDACCESSRESTRICTED: "XRootD access restricted: authorisation denied (proxy scope too narrow)",
345347
}
346348

347349
put_error_codes = [1135, 1136, 1137, 1141, 1152, 1181]
@@ -487,34 +489,53 @@ def resolve_transform_error(self, exit_code: int, stderr: str) -> tuple[int, str
487489
"No such file or directory": self.NOSUCHFILE,
488490
}
489491

492+
# Apptainer CLI version-incompatibility patterns: ALRB's
493+
# apptainerFunctions.sh probes the binary at job start with
494+
# 'apptainer buildcfg ...' purely to detect CLI capabilities, using
495+
# flags (e.g. -B) that some apptainer builds' 'buildcfg' subcommand
496+
# does not accept. This is a *different* subcommand from the one
497+
# that actually launches the payload container ('apptainer exec'),
498+
# which commonly does accept those same flags. Confirmed in
499+
# production: this probe failed with "unknown shorthand flag" while
500+
# the job's container started normally afterwards and the payload
501+
# completed with trf exit code 0. So, unlike the patterns above,
502+
# these two do not reliably indicate that the container failed to
503+
# start, and must not override an already-successful (exit_code=0)
504+
# result. They are only trusted as a genuine failure signal when the
505+
# transform itself already reported a non-zero exit code, in which
506+
# case they still provide a more specific diagnostic than the
507+
# generic PAYLOADEXECUTIONFAILURE fallback.
508+
ambiguous_apptainer_patterns = {
509+
"unknown shorthand flag": self.SINGULARITYGENERALFAILURE,
510+
"unknown flag:": self.SINGULARITYGENERALFAILURE,
511+
}
512+
490513
def get_key_by_value(d: dict, value: str) -> str:
491514
"""Return the key corresponding to a given value."""
492515
for k, v in d.items():
493516
if v == value:
494517
return k
495518
return ""
496519

497-
# Check if stderr contains any known error messages
498-
apptainer_codes = {
499-
self.SINGULARITYBINDPOINTFAILURE,
500-
self.SINGULARITYNOLOOPDEVICES,
501-
self.SINGULARITYIMAGEMOUNTFAILURE,
502-
self.SINGULARITYIMAGEMOUNTFAILURE,
503-
self.SINGULARITYGENERALFAILURE,
504-
self.SINGULARITYFAILEDUSERNAMESPACE,
505-
self.SINGULARITYNOTINSTALLED,
506-
self.APPTAINERNOTINSTALLED
507-
}
520+
# Check if stderr contains any known error messages.
521+
# Return immediately on the first match: the matched pattern is
522+
# authoritative regardless of the numeric exit code. (The previous
523+
# guard "only return when exit_code == 0" meant that any non-zero
524+
# exit code with a recognisable apptainer pattern fell through to the
525+
# generic PAYLOADEXECUTIONFAILURE fallback below.)
508526
for error_message, error_code in error_map.items():
509527
if error_message in stderr:
510-
# only allow overwriting exit code 0 for specific errors (read: apptainer)
511-
if exit_code == 0 and error_code in apptainer_codes:
528+
return error_code, error_message
529+
530+
# These patterns are only authoritative when the transform already
531+
# failed (see comment above) - do not let them override exit_code=0.
532+
if exit_code != 0:
533+
for error_message, error_code in ambiguous_apptainer_patterns.items():
534+
if error_message in stderr:
512535
return error_code, error_message
513-
else:
514-
continue
515536

516537
# Handle specific exit codes
517-
key = get_key_by_value(error_map, exit_code)
538+
key = get_key_by_value({**error_map, **ambiguous_apptainer_patterns}, exit_code)
518539
if exit_code == 2:
519540
return self.LSETUPTIMEDOUT, key
520541
if exit_code == 3:

pilot/control/job.py

Lines changed: 106 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
SERVER_UPDATE_NOT_DONE
110110
)
111111
from pilot.util.container import execute
112+
from pilot.util.features import MachineFeatures
112113
from pilot.util.filehandling import (
113114
copy,
114115
create_symlink,
@@ -182,6 +183,12 @@
182183
logger = logging.getLogger(__name__)
183184
pilot_cache = get_pilot_cache()
184185

186+
# Minimum time (s) that must remain before the MachineFeatures shutdowntime for the pilot to
187+
# fetch a new job. Chosen to comfortably exceed the monitor's own shutdowntime grace window
188+
# (10 * 60 = 600s, see pilot.control.monitor.run_shutdowntime_minute_check()) plus typical
189+
# job setup/stage-in/stage-out overhead, so a freshly-started job is not immediately killed.
190+
MIN_TIME_FOR_NEW_JOB = 1800
191+
185192

186193
def control(queues: namedtuple, traces: Any, args: object) -> None:
187194
"""Set up job control threads.
@@ -760,7 +767,13 @@ def send_state(job: Any, args: Any, state: str, xml: str = "", metadata: str = "
760767

761768
# Backchannel only makes sense on accepted responses
762769
if result.response is not None:
763-
handle_backchannel_command(result.response, job, args, test_tobekilled=test_tobekilled)
770+
# note: the api/v1 endpoint nests backchannel fields (command, pilotSecrets) inside
771+
# response['data'], e.g. {'success': True, 'data': {'command': 'tobekilled', ..}}.
772+
# Normalize the response before dispatching so that handle_backchannel_command() can
773+
# find these fields regardless of whether the enveloped or legacy flat shape was returned
774+
# (fixes: tobekilled/debug/softkill/nocleanup/pilotSecrets silently ignored for api/v1 responses).
775+
backchannel_data = extract_backchannel_data(result.response)
776+
handle_backchannel_command(backchannel_data, job, args, test_tobekilled=test_tobekilled)
764777

765778
if final:
766779
os.environ["SERVER_UPDATE"] = SERVER_UPDATE_FINAL
@@ -811,11 +824,45 @@ def get_debug_command(cmd: str) -> tuple[bool, str]:
811824
return debug_mode, debug_command
812825

813826

827+
def extract_backchannel_data(res: dict) -> dict:
828+
"""Normalize a PanDA server update response into a flat backchannel dict.
829+
830+
The current REST API (``api/v1/pilot/update_job``) nests backchannel fields such as
831+
``command`` and ``pilotSecrets`` inside ``res['data']``, e.g.
832+
``{'success': True, 'message': '', 'data': {'StatusCode': 0, 'command': 'tobekilled'}}``.
833+
Older/legacy server responses returned these fields directly at the top level. This
834+
function merges both layers into a single flat dict so that
835+
:func:`handle_backchannel_command` can look fields up in one place regardless of which
836+
response shape was actually received.
837+
838+
Nested ``data`` fields take precedence over top-level fields on key collisions, since
839+
``data`` reflects the current API format.
840+
841+
Args:
842+
res: raw server response (either the enveloped api/v1 shape or a legacy flat dict).
843+
844+
Returns:
845+
dict: flattened dict containing both top-level and formerly-nested-under-'data' keys,
846+
ready for backchannel command lookups.
847+
"""
848+
if not isinstance(res, dict):
849+
return {}
850+
851+
merged = dict(res)
852+
data = res.get('data')
853+
if isinstance(data, dict):
854+
merged.update(data)
855+
856+
return merged
857+
858+
814859
def handle_backchannel_command(res: dict, job: Any, args: Any, test_tobekilled: bool = False) -> None:
815860
"""Check if the server update contain any backchannel information. If so, update the job object.
816861
817862
Args:
818-
res: server response.
863+
res: normalized server response (see extract_backchannel_data()) - a flat dict in which
864+
'command' and 'pilotSecrets', if present, are looked up at the top level regardless
865+
of whether the original server response nested them under 'data'.
819866
job: job object.
820867
args: pilot args object.
821868
test_tobekilled: emulate a tobekilled command.
@@ -1794,8 +1841,51 @@ def get_dispatcher_dictionary(args: Any, taskid: str = "") -> dict:
17941841
return data
17951842

17961843

1844+
def _time_until_shutdown(args: Any) -> Optional[int]:
1845+
"""Return the number of seconds until the MachineFeatures shutdowntime, if known.
1846+
1847+
This mirrors the shutdowntime lookup performed by
1848+
pilot.control.monitor.run_shutdowntime_minute_check(), but is used here to decide
1849+
whether it is worth fetching a new job at all -- not to abort an already running one.
1850+
1851+
Args:
1852+
args: pilot arguments (used to determine time since pilot start, for the same
1853+
staleness check applied in run_shutdowntime_minute_check()).
1854+
1855+
Returns:
1856+
Optional[int]: seconds remaining until shutdowntime, or None if shutdowntime is not
1857+
set, not parseable, or refers to a time before the pilot started (stale value).
1858+
"""
1859+
machinefeatures = MachineFeatures().get()
1860+
if not machinefeatures:
1861+
return None
1862+
1863+
_shutdowntime = machinefeatures.get('shutdowntime', None)
1864+
if not _shutdowntime:
1865+
return None
1866+
1867+
try:
1868+
shutdowntime = int(_shutdowntime)
1869+
except (TypeError, ValueError) as exc:
1870+
logger.warning(f'failed to convert shutdowntime: {exc}')
1871+
return None
1872+
1873+
now = int(time.time())
1874+
time_since_start = get_time_since_start(args)
1875+
1876+
# ignore shutdowntime if it predates pilot start (stale value) -- same convention as
1877+
# pilot.control.monitor.run_shutdowntime_minute_check()
1878+
if shutdowntime < (now - time_since_start):
1879+
logger.debug(f'shutdowntime ({shutdowntime}) was set before pilot started - ignoring it '
1880+
f'(now - time since start = {now - time_since_start})')
1881+
return None
1882+
1883+
return shutdowntime - now
1884+
1885+
17971886
def proceed_with_getjob(timefloor: int, starttime: int, jobnumber: int, getjob_requests: int, max_getjob_requests: int, # noqa: C901
1798-
should_update_server: bool, submitmode: str, harvester: bool, verify_proxy: bool, traces: Any) -> bool: # noqa: C901
1887+
should_update_server: bool, submitmode: str, harvester: bool, verify_proxy: bool, traces: Any, # noqa: C901
1888+
args: Any = None) -> bool:
17991889
"""Check if we can proceed with getJob.
18001890
18011891
We may not proceed if we have run out of time (timefloor limit), if the proxy is too short, if disk space is too
@@ -1812,6 +1902,8 @@ def proceed_with_getjob(timefloor: int, starttime: int, jobnumber: int, getjob_r
18121902
harvester: True if Harvester is used, False otherwise. Affects the max number of getjob reads from file.
18131903
verify_proxy: True if the proxy should be verified. False otherwise.
18141904
traces: traces object (to be able to propagate a proxy error all the way back to the wrapper).
1905+
args: pilot arguments, used to check the MachineFeatures shutdowntime before fetching a
1906+
new job (optional; the shutdowntime check is skipped if not provided).
18151907
18161908
Returns:
18171909
bool: True if pilot should proceed with getJob.
@@ -1874,6 +1966,16 @@ def proceed_with_getjob(timefloor: int, starttime: int, jobnumber: int, getjob_r
18741966
if jobnumber > 0:
18751967
logger.info(f'since timefloor={timefloor} s and only {currenttime - starttime} s has passed since launch, pilot can run another job')
18761968

1969+
# do not fetch a new job if the node is about to be reclaimed (MachineFeatures shutdowntime) --
1970+
# not relevant for the first job, which is handled separately by the batch system / wrapper
1971+
if jobnumber > 0 and args is not None:
1972+
remaining = _time_until_shutdown(args)
1973+
if remaining is not None and remaining < MIN_TIME_FOR_NEW_JOB:
1974+
return wrap_up_quickly(
1975+
f'insufficient time remaining before node shutdown ({remaining}s < '
1976+
f'{MIN_TIME_FOR_NEW_JOB}s minimum) - will not fetch another job'
1977+
)
1978+
18771979
if harvester and jobnumber > 0:
18781980
# unless it's the first job (which is preplaced in the init dir), instruct Harvester to place another job
18791981
# in the init dir
@@ -2720,6 +2822,7 @@ def _wait_for_harvester_job_definition(timeout: int, poll_interval: float) -> bo
27202822
args.harvester,
27212823
args.verify_proxy,
27222824
traces,
2825+
args,
27232826
)
27242827
except Exception as exc:
27252828
logger.warning(f"proceed_with_getjob() raised exception: {exc}")

0 commit comments

Comments
 (0)