109109 SERVER_UPDATE_NOT_DONE
110110)
111111from pilot .util .container import execute
112+ from pilot .util .features import MachineFeatures
112113from pilot .util .filehandling import (
113114 copy ,
114115 create_symlink ,
182183logger = logging .getLogger (__name__ )
183184pilot_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
186193def 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+
814859def 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+
17971886def 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