Back to home page

EIC code displayed by LXR

 
 

    


Warning, file /panda-server/pandaserver/workflow/tests/workflow_done_transition_test.py was not indexed or was modified since last indexation (in which case cross-reference links may be missing, inaccurate or erroneous).

0001 """
0002 Offline check of when a running workflow is allowed to become done.
0003 
0004 A step reaches done as soon as its task does, but the task's output dataset is closed in DDM
0005 slightly later, so the data pass that moves an output from generating_suffice to done_generated
0006 necessarily runs a cycle behind the step transition. Finishing the workflow on the step transition
0007 alone leaves that output non-terminal for good, since a done workflow is no longer in
0008 active_statuses and is never processed again. This was seen for real: workflow 133 finished with
0009 merge_ntup/NTUP_PILEUP frozen in generating_suffice.
0010 
0011 So the workflow waits for its outputs, and the wait is bounded, so that an output whose dataset is
0012 never closed cannot keep the workflow running forever.
0013 
0014 Run from the repository root:  python3 pandaserver/workflow/tests/workflow_done_transition_test.py
0015 """
0016 
0017 import copy
0018 import importlib.abc
0019 import importlib.machinery
0020 import json
0021 import os
0022 import sys
0023 import types
0024 import warnings
0025 from datetime import datetime, timedelta
0026 
0027 # Unrelated modules in the import graph still carry unescaped regex literals; their SyntaxWarnings
0028 # say nothing about this check.
0029 warnings.filterwarnings("ignore", category=SyntaxWarning)
0030 
0031 REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
0032 sys.path.insert(0, REPO_ROOT)
0033 
0034 AUTO_STUB_ROOTS = ("idds", "pandaclient", "ruamel", "requests", "rucio")
0035 
0036 
0037 class AutoStubFinder(importlib.abc.MetaPathFinder, importlib.abc.Loader):
0038     def find_spec(self, name, path=None, target=None):
0039         if name.split(".")[0] in AUTO_STUB_ROOTS:
0040             return importlib.machinery.ModuleSpec(name, self, is_package=True)
0041         return None
0042 
0043     def create_module(self, spec):
0044         module = types.ModuleType(spec.name)
0045         module.__path__ = []
0046         return module
0047 
0048     def exec_module(self, module):
0049         class Anything:
0050             def __init__(self, *a, **k):
0051                 pass
0052 
0053             def __call__(self, *a, **k):
0054                 return self
0055 
0056         module.__getattr__ = lambda name: Anything
0057 
0058 
0059 sys.meta_path.insert(0, AutoStubFinder())
0060 
0061 
0062 def stub(name, **attrs):
0063     module = types.ModuleType(name)
0064     for key, value in attrs.items():
0065         setattr(module, key, value)
0066     sys.modules[name] = module
0067     return module
0068 
0069 
0070 class Log:
0071     """Collects what the code under test logs, so the warnings it emits can be asserted on"""
0072 
0073     def __init__(self, *a, **k):
0074         self.messages = []
0075 
0076     def info(self, m):
0077         self.messages.append(("info", str(m)))
0078 
0079     def debug(self, m):
0080         self.messages.append(("debug", str(m)))
0081 
0082     def warning(self, m):
0083         self.messages.append(("warning", str(m)))
0084 
0085     def error(self, m):
0086         self.messages.append(("error", str(m)))
0087 
0088 
0089 class SpecBase:
0090     """Enough of pandacommon's SpecBase for the real spec classes to be instantiated offline"""
0091 
0092     def __init__(self):
0093         for attribute in getattr(self, "attributes", ()):
0094             setattr(self, attribute, None)
0095 
0096 
0097 stub("pandacommon")
0098 stub("pandacommon.pandautils").__path__ = []
0099 stub("pandacommon.pandautils.base", SpecBase=SpecBase)
0100 stub("pandacommon.pandautils.PandaUtils", naive_utcnow=datetime.utcnow, get_sql_IN_bind_variables=lambda *a, **k: (None, None))
0101 stub("pandacommon.pandalogger").__path__ = []
0102 stub("pandacommon.pandalogger.LogWrapper", LogWrapper=Log)
0103 stub("pandacommon.pandalogger.PandaLogger", PandaLogger=lambda: types.SimpleNamespace(getLogger=lambda n: None))
0104 stub("pandaserver.config", panda_config=types.SimpleNamespace(schemaJEDI="ATLAS_PANDA", schemaDEFT="ATLAS_DEFT"))
0105 
0106 from pandaserver.workflow import workflow_core  # noqa: E402
0107 from pandaserver.workflow.workflow_base import (  # noqa: E402
0108     WFDataStatus,
0109     WFDataTargetCheckStatus,
0110     WFDataType,
0111     WFStepStatus,
0112     WorkflowSpec,
0113     WorkflowStatus,
0114 )
0115 
0116 NOW = datetime(2026, 9, 10, 12, 0, 0)
0117 
0118 # process_workflow_running stamps its own "now" from naive_utcnow, which the bounded-wait case has
0119 # to be able to place relative to the recorded wait, so the clock is pinned here.
0120 workflow_core.naive_utcnow = lambda: NOW
0121 
0122 
0123 class FakeData:
0124     def __init__(self, name, status, data_type=WFDataType.output, data_id=1, flavor="ddm_collection"):
0125         self.name = name
0126         self.status = status
0127         self.type = data_type
0128         self.data_id = data_id
0129         self.workflow_id = 133
0130         self.flavor = flavor
0131         self.end_time = None
0132         self.check_time = None
0133 
0134 
0135 class FakeStep:
0136     _next_step_id = 500
0137 
0138     def __init__(self, name, status=WFStepStatus.done, flavor="panda_task", target_id="1", step_id=None):
0139         self.name = name
0140         self.status = status
0141         self.flavor = flavor
0142         self.target_id = target_id
0143         self.member_id = 1
0144         self.workflow_id = 133
0145         self.end_time = None
0146         if step_id is None:
0147             FakeStep._next_step_id += 1
0148             step_id = FakeStep._next_step_id
0149         self.step_id = step_id
0150         self.parameters = None
0151 
0152     # The engine records why a step reached a terminal status through these, and stores them as
0153     # JSON, so the fake serializes too rather than keeping a dict a non-serializable value would
0154     # pass through unnoticed.
0155     def get_parameter(self, name):
0156         return json.loads(self.parameters).get(name) if self.parameters else None
0157 
0158     def set_parameter(self, name, value):
0159         parameter_map = json.loads(self.parameters) if self.parameters else {}
0160         parameter_map[name] = value
0161         self.parameters = json.dumps(parameter_map)
0162 
0163 
0164 class FakeWorkflowRow:
0165     """A child workflow as the task buffer would hand it back"""
0166 
0167     def __init__(self, workflow_id, status=WorkflowStatus.running):
0168         self.workflow_id = workflow_id
0169         self.status = status
0170 
0171 
0172 class FakeStepHandler:
0173     """Records the targets it was asked to kill, so the kills themselves can be asserted on"""
0174 
0175     def __init__(self, succeed=True):
0176         self.succeed = succeed
0177         self.cancelled_target_ids = []
0178 
0179     def cancel_target(self, step_spec, **kwargs):
0180         if step_spec.target_id is None:
0181             # as the real handler does: no task was ever submitted, so there is nothing to kill and
0182             # the step is treated as already cancelled
0183             return types.SimpleNamespace(success=True, message="target_id is None so considered already cancelled; skipped")
0184         self.cancelled_target_ids.append(step_spec.target_id)
0185         message = "" if self.succeed else "failed to cancel the task: error_code=1"
0186         return types.SimpleNamespace(success=self.succeed, message=message)
0187 
0188 
0189 class FakeTaskBuffer:
0190     def __init__(self, data_specs, step_specs):
0191         self.data_specs = data_specs
0192         self.step_specs = step_specs
0193         self.updated_workflows = []
0194         self.updated_data = []
0195         # one ordered record of everything written, so the order of the cleanup against the status
0196         # write can be asserted on rather than assumed
0197         self.call_log = []
0198         self.child_workflows = {}
0199         self.unlockable_step_ids = set()
0200         self.n_get_steps_calls = 0
0201         self.raise_on_get_steps_call = None
0202 
0203     def get_data_of_workflow(self, workflow_id, status_exclusion_list=None, type_filter_list=None):
0204         return list(self.data_specs)
0205 
0206     def get_steps_of_workflow(self, workflow_id, status_filter_list=None):
0207         self.n_get_steps_calls += 1
0208         if self.raise_on_get_steps_call == self.n_get_steps_calls:
0209             raise RuntimeError("database is unavailable")
0210         # Copies, because a real query returns rows read at the time it ran. Handing back the live
0211         # objects would hide the staleness this cleanup has to survive: the caller's list is read
0212         # before the steps are processed, and process_steps never writes its locked copies back.
0213         return [copy.copy(step_spec) for step_spec in self.step_specs]
0214 
0215     def get_workflow_step(self, step_id):
0216         for step_spec in self.step_specs:
0217             if step_spec.step_id == step_id:
0218                 return step_spec
0219         return None
0220 
0221     def lock_workflow_step(self, step_id, locked_by, lock_expiration_sec=120):
0222         return step_id not in self.unlockable_step_ids
0223 
0224     def unlock_workflow_step(self, step_id, locked_by):
0225         return True
0226 
0227     def update_workflow_step(self, step_spec):
0228         self.call_log.append(("update_workflow_step", step_spec.step_id, step_spec.status))
0229         return True
0230 
0231     def get_workflow(self, workflow_id):
0232         return self.child_workflows.get(workflow_id)
0233 
0234     def update_workflow(self, workflow_spec):
0235         self.call_log.append(("update_workflow", workflow_spec.status))
0236         self.updated_workflows.append((workflow_spec.status, workflow_spec.parameters))
0237         return True
0238 
0239     def update_workflow_data(self, data_spec):
0240         self.updated_data.append((data_spec.name, data_spec.status))
0241         return True
0242 
0243 
0244 def make_workflow(steps_final_time=None):
0245     workflow_spec = WorkflowSpec()
0246     workflow_spec.workflow_id = 133
0247     workflow_spec.status = WorkflowStatus.running
0248     if steps_final_time is not None:
0249         workflow_spec.set_parameter(workflow_core.STEPS_FINAL_TIME_PARAM, steps_final_time)
0250     return workflow_spec
0251 
0252 
0253 class FakeDataHandler:
0254     """Reports whatever DDM state a case wants, so the waiting transitions can be driven"""
0255 
0256     def __init__(self, check_status):
0257         self.check_status = check_status
0258 
0259     def check_target(self, data_spec, **kwargs):
0260         result = types.SimpleNamespace(success=True, check_status=self.check_status, message="", metadata={})
0261         return result
0262 
0263 
0264 class StubbedInterface(workflow_core.WorkflowInterface):
0265     """The real workflow transitions, with the step and data passes replaced
0266 
0267     Subclassed rather than monkey-patched so the stubs are checked against the methods they stand
0268     in for. __init__ is bypassed because the real one opens a message broker and a DDM client.
0269     """
0270 
0271     def __init__(self, data_specs, step_specs, all_steps_final=True, check_status=None, processed_stats=None, step_mutations=(), kills_succeed=True):
0272         self.tbif = FakeTaskBuffer(data_specs, step_specs)
0273         self.full_pid = "test-0-0"
0274         self.plugin_map = {}
0275         self.mb_proxy = None
0276         self._all_steps_final = all_steps_final
0277         self._data_handler = FakeDataHandler(check_status if check_status is not None else WFDataTargetCheckStatus.suffice)
0278         self._processed_stats = processed_stats
0279         self._step_mutations = step_mutations
0280         self.step_handler = FakeStepHandler(kills_succeed)
0281         self.cancelled_workflow_ids = []
0282         self.cancel_reasons = []
0283 
0284     def get_plugin(self, plugin_type, flavor):
0285         if plugin_type == "step_handler":
0286             return self.step_handler
0287         return self._data_handler
0288 
0289     # Recorded rather than run: what this harness checks is that the failing workflow asks for the
0290     # right children to be cancelled, and cancel_workflow taking the child final is what the
0291     # de-duplication between cancel_step's recursion and the orphan sweep relies on.
0292     def cancel_workflow(self, workflow_id, force=False, reason=None):
0293         self.cancelled_workflow_ids.append(workflow_id)
0294         self.cancel_reasons.append(reason)
0295         child_workflow = self.tbif.child_workflows.get(workflow_id)
0296         if child_workflow is not None:
0297             child_workflow.status = WorkflowStatus.cancelled
0298         return True
0299 
0300     # The data pass is what would advance an output on a later cycle; here it changes nothing, so
0301     # each case controls the output statuses directly.
0302     def process_datas(self, data_specs, by="dog"):
0303         return {"n_processed": len(data_specs), "processed": {}, "changed": {}}
0304 
0305     def process_steps(self, step_specs, data_spec_map=None, by="dog"):
0306         # The real one advances the stored steps while leaving the caller's list as it was, so the
0307         # mutations go to the stored objects and not to the list that was passed in.
0308         for step_id, new_status, new_target_id in self._step_mutations:
0309             stored_step = self.tbif.get_workflow_step(step_id)
0310             stored_step.status = new_status
0311             if new_target_id is not None:
0312                 stored_step.target_id = new_target_id
0313         if self._processed_stats is not None:
0314             return {"n_processed": len(step_specs), "processed": dict(self._processed_stats), "changed": {}}
0315         status = WFStepStatus.done if self._all_steps_final else WFStepStatus.running
0316         return {"n_processed": len(step_specs), "processed": {status: len(step_specs)}, "changed": {}}
0317 
0318 
0319 def make_interface(data_specs, step_specs, all_steps_final=True, check_status=None, **kwargs):
0320     return StubbedInterface(data_specs, step_specs, all_steps_final, check_status, **kwargs)
0321 
0322 
0323 def status_of(interface, step_id):
0324     return interface.tbif.get_workflow_step(step_id).status
0325 
0326 
0327 def index_of_first(call_log, name):
0328     for index, entry in enumerate(call_log):
0329         if entry[0] == name:
0330             return index
0331     return -1
0332 
0333 
0334 def check(label, condition, detail=""):
0335     print(f"  {'PASS' if condition else 'FAIL'}  {label}{'  ' + str(detail) if detail and not condition else ''}")
0336     return condition
0337 
0338 
0339 def main():
0340     failures = 0
0341     interface = make_interface([], [])
0342 
0343     print("\n=== are_all_outputs_good is three-valued ===")
0344     failures += not check("no output at all -> None", interface.are_all_outputs_good({}) is None)
0345     good = {"a": FakeData("a", WFDataStatus.done_generated), "b": FakeData("b", WFDataStatus.done_waited)}
0346     failures += not check("every output terminal -> True", interface.are_all_outputs_good(good) is True)
0347     mixed = {"a": FakeData("a", WFDataStatus.done_generated), "b": FakeData("b", WFDataStatus.generating_suffice)}
0348     failures += not check("one output still generating -> False", interface.are_all_outputs_good(mixed) is False)
0349 
0350     print("\n=== record_steps_final_time measures the wait ===")
0351     workflow_spec = make_workflow()
0352     failures += not check("first cycle reports 0", interface.record_steps_final_time(workflow_spec, NOW) == 0)
0353     failures += not check(
0354         "first cycle records the time",
0355         workflow_spec.get_parameter(workflow_core.STEPS_FINAL_TIME_PARAM) == NOW.isoformat(),
0356         workflow_spec.parameters,
0357     )
0358     failures += not check(
0359         "a later cycle reports the elapsed seconds",
0360         interface.record_steps_final_time(workflow_spec, NOW + timedelta(seconds=125)) == 125,
0361     )
0362     broken = make_workflow(steps_final_time="not-a-timestamp")
0363     failures += not check("an unparsable value restarts the wait instead of hanging", interface.record_steps_final_time(broken, NOW) == 0)
0364     failures += not check("...and is replaced", broken.get_parameter(workflow_core.STEPS_FINAL_TIME_PARAM) == NOW.isoformat())
0365 
0366     print("\n=== a workflow whose steps are all final waits for its outputs ===")
0367     # This is workflow 133: every step done, the last output still generating_suffice because its
0368     # dataset is closed in DDM only after the task reached done.
0369     lagging = FakeData("merge_ntup/NTUP_PILEUP", WFDataStatus.generating_suffice)
0370     data_specs = [FakeData("deriv_phys/DAOD_PHYS", WFDataStatus.done_generated), lagging]
0371     interface = make_interface(data_specs, [FakeStep("merge_ntup"), FakeStep("deriv_phys")])
0372     workflow_spec = make_workflow()
0373     result = interface.process_workflow_running(workflow_spec)
0374     failures += not check("stays running", workflow_spec.status == WorkflowStatus.running, workflow_spec.status)
0375     failures += not check("reports success rather than an error", result.success is True, result.message)
0376     failures += not check("does not announce a new status", result.new_status is None, result.new_status)
0377     failures += not check("the lagging output is untouched", lagging.status == WFDataStatus.generating_suffice, lagging.status)
0378     failures += not check("the wait is recorded on the workflow", workflow_spec.get_parameter(workflow_core.STEPS_FINAL_TIME_PARAM) is not None)
0379     failures += not check("the recorded wait is persisted", interface.tbif.updated_workflows and interface.tbif.updated_workflows[-1][1])
0380 
0381     print("\n=== the output catching up finishes the workflow ===")
0382     data_specs = [FakeData("deriv_phys/DAOD_PHYS", WFDataStatus.done_generated), FakeData("merge_ntup/NTUP_PILEUP", WFDataStatus.done_generated)]
0383     interface = make_interface(data_specs, [FakeStep("merge_ntup"), FakeStep("deriv_phys")])
0384     workflow_spec = make_workflow(steps_final_time=NOW.isoformat())
0385     result = interface.process_workflow_running(workflow_spec)
0386     failures += not check("becomes done", workflow_spec.status == WorkflowStatus.done, workflow_spec.status)
0387     failures += not check("nothing had to be settled", not interface.tbif.updated_data, interface.tbif.updated_data)
0388 
0389     print("\n=== the wait is bounded ===")
0390     stuck = FakeData("merge_ntup/NTUP_PILEUP", WFDataStatus.generating_suffice)
0391     interface = make_interface([stuck], [FakeStep("merge_ntup")])
0392     expired = (NOW - timedelta(seconds=workflow_core.OUTPUT_SETTLE_GRACE_SEC + 1)).isoformat()
0393     workflow_spec = make_workflow(steps_final_time=expired)
0394     result = interface.process_workflow_running(workflow_spec)
0395     failures += not check("becomes done once the grace period is over", workflow_spec.status == WorkflowStatus.done, workflow_spec.status)
0396     failures += not check("the outstanding output is settled", stuck.status == WFDataStatus.done_generated, stuck.status)
0397     failures += not check("the settled output is persisted", ("merge_ntup/NTUP_PILEUP", WFDataStatus.done_generated) in interface.tbif.updated_data)
0398 
0399     print("\n=== settle_pending_outputs only settles what it can ===")
0400     log = Log()
0401     generating = FakeData("out/generating", WFDataStatus.generating_insuffi)
0402     waiting = FakeData("out/waiting", WFDataStatus.waiting_suffice)
0403     already = FakeData("out/already", WFDataStatus.done_waited)
0404     never_bound = FakeData("out/never_bound", WFDataStatus.checking)
0405     interface = make_interface([], [])
0406     interface.settle_pending_outputs(log, {d.name: d for d in [generating, waiting, already, never_bound]}, NOW)
0407     failures += not check("generating -> done_generated", generating.status == WFDataStatus.done_generated, generating.status)
0408     failures += not check("waiting -> done_waited", waiting.status == WFDataStatus.done_waited, waiting.status)
0409     failures += not check("an already terminal output is left alone", already.status == WFDataStatus.done_waited)
0410     failures += not check("one that was never bound is not given a final status", never_bound.status == WFDataStatus.checking, never_bound.status)
0411     failures += not check("every settled output is reported as a warning", sum(1 for level, _ in log.messages if level == "warning") == 3, log.messages)
0412 
0413     print("\n=== a workflow with no output data still finishes ===")
0414     interface = make_interface([], [FakeStep("only_step")])
0415     workflow_spec = make_workflow()
0416     result = interface.process_workflow_running(workflow_spec)
0417     failures += not check("becomes done rather than waiting for outputs it does not have", workflow_spec.status == WorkflowStatus.done, workflow_spec.status)
0418 
0419     print("\n=== steps that are not all final are unaffected ===")
0420     interface = make_interface([FakeData("out/a", WFDataStatus.generating_suffice)], [FakeStep("a", WFStepStatus.running)], all_steps_final=False)
0421     workflow_spec = make_workflow()
0422     result = interface.process_workflow_running(workflow_spec)
0423     failures += not check("stays running", workflow_spec.status == WorkflowStatus.running, workflow_spec.status)
0424     failures += not check("no wait is recorded", workflow_spec.get_parameter(workflow_core.STEPS_FINAL_TIME_PARAM) is None)
0425     failures += not check(
0426         "nothing is killed while the workflow is healthy", interface.step_handler.cancelled_target_ids == [], interface.step_handler.cancelled_target_ids
0427     )
0428 
0429     print("\n=== a failed workflow cancels its unfinished steps and kills their tasks ===")
0430     # The failure a consumer actually meets: one task of a chain fails, and everything else the
0431     # chain had running must be stopped rather than left on the grid with nothing to end it.
0432     broke = FakeStep("recon", WFStepStatus.running, target_id="48810706")
0433     sibling = FakeStep("deriv_phys", WFStepStatus.running, target_id="48810720")
0434     never_started = FakeStep("merge_ntup", WFStepStatus.pending, target_id=None)
0435     finished = FakeStep("evgen", WFStepStatus.done, target_id="48810693")
0436     skipped = FakeStep("optional", WFStepStatus.closed, target_id=None)
0437     interface = make_interface(
0438         [],
0439         [broke, sibling, never_started, finished, skipped],
0440         processed_stats={WFStepStatus.failed: 1, WFStepStatus.running: 1, WFStepStatus.pending: 1, WFStepStatus.done: 1, WFStepStatus.closed: 1},
0441         step_mutations=[(broke.step_id, WFStepStatus.failed, None)],
0442     )
0443     workflow_spec = make_workflow()
0444     result = interface.process_workflow_running(workflow_spec)
0445     failures += not check("the workflow fails", workflow_spec.status == WorkflowStatus.failed, workflow_spec.status)
0446     failures += not check(
0447         "the running sibling is cancelled", status_of(interface, sibling.step_id) == WFStepStatus.cancelled, status_of(interface, sibling.step_id)
0448     )
0449     failures += not check("...and its end time is stamped", sibling.end_time is not None)
0450     failures += not check(
0451         "...and the cancellation is persisted",
0452         ("update_workflow_step", sibling.step_id, WFStepStatus.cancelled) in interface.tbif.call_log,
0453         interface.tbif.call_log,
0454     )
0455     # The load-bearing one: without it this passes on a version that only rewrites statuses in the
0456     # database and leaves the tasks running, which is the defect being fixed.
0457     failures += not check(
0458         "the sibling's task is actually killed",
0459         interface.step_handler.cancelled_target_ids == ["48810720"],
0460         interface.step_handler.cancelled_target_ids,
0461     )
0462     failures += not check(
0463         "the step that failed keeps its status", status_of(interface, broke.step_id) == WFStepStatus.failed, status_of(interface, broke.step_id)
0464     )
0465     failures += not check("...and is not killed a second time", "48810706" not in interface.step_handler.cancelled_target_ids)
0466     failures += not check(
0467         "a step that never started is cancelled too",
0468         status_of(interface, never_started.step_id) == WFStepStatus.cancelled,
0469         status_of(interface, never_started.step_id),
0470     )
0471     failures += not check(
0472         "...without a kill being sent for it", len(interface.step_handler.cancelled_target_ids) == 1, interface.step_handler.cancelled_target_ids
0473     )
0474     failures += not check("a finished step is left done", status_of(interface, finished.step_id) == WFStepStatus.done, status_of(interface, finished.step_id))
0475     failures += not check("a closed step stays closed", status_of(interface, skipped.step_id) == WFStepStatus.closed, status_of(interface, skipped.step_id))
0476     failures += not check("neither is killed", "48810693" not in interface.step_handler.cancelled_target_ids)
0477     failures += not check("no step is reported unreachable", workflow_spec.get_parameter(workflow_core.UNCANCELLED_STEPS_PARAM) is None)
0478 
0479     print("\n=== the failure says which step caused it, and the cancellations say they were collateral ===")
0480     # Status alone cannot tell a step cancelled with a failing workflow from one cancelled on
0481     # purpose, and the workflow would otherwise say only that it failed.
0482     workflow_reason = workflow_spec.get_parameter(workflow_core.WORKFLOW_FAIL_REASON_PARAM)
0483     failures += not check("the workflow names the step that failed", workflow_reason and "recon" in workflow_reason, workflow_reason)
0484     failures += not check("...and its step_id", workflow_reason and f"step_id={broke.step_id}" in workflow_reason, workflow_reason)
0485     sibling_reason = interface.tbif.get_workflow_step(sibling.step_id).get_parameter(workflow_core.STEP_REASON_PARAM)
0486     failures += not check(
0487         "a cancelled sibling says it went with the workflow", sibling_reason and "cancelled with the workflow" in sibling_reason, sibling_reason
0488     )
0489     failures += not check("...and repeats which step failed", sibling_reason and "recon" in sibling_reason, sibling_reason)
0490 
0491     print("\n=== the cancelling happens before the workflow is marked failed ===")
0492     # An exception part way through must leave the workflow running so the next cycle retries;
0493     # marking it failed first would strand whatever had not been reached yet.
0494     first_status_write = index_of_first(interface.tbif.call_log, "update_workflow")
0495     last_step_write = max(index for index, entry in enumerate(interface.tbif.call_log) if entry[0] == "update_workflow_step")
0496     failures += not check("every step write precedes the workflow write", last_step_write < first_status_write, interface.tbif.call_log)
0497 
0498     print("\n=== a failed step records what its target reported ===")
0499     # check_target knows the JEDI status and the engine used to throw it away, leaving "failed" as
0500     # the only thing anyone could read afterwards.
0501     step = FakeStep("recon", WFStepStatus.running, target_id="48810706")
0502     interface = make_interface([], [step])
0503     check_result = types.SimpleNamespace(native_status="broken", message="", step_status=WFStepStatus.failed)
0504     step.status = WFStepStatus.failed
0505     interface.record_step_reason(step, check_result)
0506     reason = step.get_parameter(workflow_core.STEP_REASON_PARAM)
0507     failures += not check("it names the task and what it reported", reason == "task 48810706 reported broken", reason)
0508     sub = FakeStep("sig_bg_comb", WFStepStatus.failed, flavor="sub_workflow", target_id="900")
0509     interface.record_step_reason(sub, types.SimpleNamespace(native_status="failed", message="", step_status=WFStepStatus.failed))
0510     failures += not check(
0511         "a sub-workflow step names the child workflow",
0512         sub.get_parameter(workflow_core.STEP_REASON_PARAM) == "child workflow 900 reported failed",
0513         sub.get_parameter(workflow_core.STEP_REASON_PARAM),
0514     )
0515     done_step = FakeStep("evgen", WFStepStatus.done, target_id="1")
0516     interface.record_step_reason(done_step, types.SimpleNamespace(native_status="done", message="", step_status=WFStepStatus.done))
0517     failures += not check("a step that did not fail records nothing", done_step.get_parameter(workflow_core.STEP_REASON_PARAM) is None)
0518 
0519     print("\n=== a sub-workflow step that failed during this very cycle is not missed ===")
0520     # The staleness regression. The list read before the steps were processed says running with no
0521     # target; only a re-read sees the failure and the child workflow it left behind.
0522     sub_step = FakeStep("sig_bg_comb", WFStepStatus.running, flavor="sub_workflow", target_id=None)
0523     interface = make_interface(
0524         [],
0525         [sub_step],
0526         processed_stats={WFStepStatus.failed: 1},
0527         step_mutations=[(sub_step.step_id, WFStepStatus.failed, "900")],
0528     )
0529     interface.tbif.child_workflows[900] = FakeWorkflowRow(900, WorkflowStatus.running)
0530     workflow_spec = make_workflow()
0531     result = interface.process_workflow_running(workflow_spec)
0532     failures += not check("the orphaned child workflow is cancelled", interface.cancelled_workflow_ids == [900], interface.cancelled_workflow_ids)
0533     failures += not check("the workflow fails", workflow_spec.status == WorkflowStatus.failed, workflow_spec.status)
0534 
0535     print("\n=== an unfinished sub-workflow step cancels its child exactly once ===")
0536     live_sub = FakeStep("sig_bg_comb", WFStepStatus.running, flavor="sub_workflow", target_id="901")
0537     other = FakeStep("broke", WFStepStatus.failed, target_id="48810706")
0538     interface = make_interface([], [live_sub, other], processed_stats={WFStepStatus.failed: 1, WFStepStatus.running: 1})
0539     interface.tbif.child_workflows[901] = FakeWorkflowRow(901, WorkflowStatus.running)
0540     workflow_spec = make_workflow()
0541     result = interface.process_workflow_running(workflow_spec)
0542     failures += not check("the child is cancelled once, not twice", interface.cancelled_workflow_ids == [901], interface.cancelled_workflow_ids)
0543     failures += not check("the step is cancelled", status_of(interface, live_sub.step_id) == WFStepStatus.cancelled, status_of(interface, live_sub.step_id))
0544     failures += not check(
0545         "a sub-workflow step issues no task kill", interface.step_handler.cancelled_target_ids == [], interface.step_handler.cancelled_target_ids
0546     )
0547 
0548     print("\n=== a child workflow already final is left alone ===")
0549     done_sub = FakeStep("sig_bg_comb", WFStepStatus.done, flavor="sub_workflow", target_id="902")
0550     interface = make_interface(
0551         [], [done_sub, FakeStep("broke", WFStepStatus.failed, target_id="1")], processed_stats={WFStepStatus.failed: 1, WFStepStatus.done: 1}
0552     )
0553     interface.tbif.child_workflows[902] = FakeWorkflowRow(902, WorkflowStatus.done)
0554     interface.process_workflow_running(make_workflow())
0555     failures += not check("nothing is cancelled", interface.cancelled_workflow_ids == [], interface.cancelled_workflow_ids)
0556 
0557     print("\n=== a kill that fails still fails the workflow ===")
0558     stubborn = FakeStep("deriv_phys", WFStepStatus.running, target_id="48810720")
0559     interface = make_interface(
0560         [],
0561         [stubborn, FakeStep("broke", WFStepStatus.failed, target_id="48810706")],
0562         processed_stats={WFStepStatus.failed: 1, WFStepStatus.running: 1},
0563         kills_succeed=False,
0564     )
0565     workflow_spec = make_workflow()
0566     result = interface.process_workflow_running(workflow_spec)
0567     failures += not check("the kill was attempted", interface.step_handler.cancelled_target_ids == ["48810720"], interface.step_handler.cancelled_target_ids)
0568     failures += not check(
0569         "the step is force cancelled anyway", status_of(interface, stubborn.step_id) == WFStepStatus.cancelled, status_of(interface, stubborn.step_id)
0570     )
0571     failures += not check("the workflow still fails", workflow_spec.status == WorkflowStatus.failed, workflow_spec.status)
0572     failures += not check("the step is not reported unreachable", workflow_spec.get_parameter(workflow_core.UNCANCELLED_STEPS_PARAM) is None)
0573     stubborn_reason = interface.tbif.get_workflow_step(stubborn.step_id).get_parameter(workflow_core.STEP_REASON_PARAM)
0574     failures += not check("the row admits the target was not cancelled", stubborn_reason and "target was not cancelled" in stubborn_reason, stubborn_reason)
0575 
0576     print("\n=== a step that cannot be locked is named on the workflow ===")
0577     held = FakeStep("deriv_phys", WFStepStatus.running, target_id="48810720")
0578     interface = make_interface(
0579         [], [held, FakeStep("broke", WFStepStatus.failed, target_id="1")], processed_stats={WFStepStatus.failed: 1, WFStepStatus.running: 1}
0580     )
0581     interface.tbif.unlockable_step_ids.add(held.step_id)
0582     workflow_spec = make_workflow()
0583     result = interface.process_workflow_running(workflow_spec)
0584     failures += not check("the workflow fails regardless", workflow_spec.status == WorkflowStatus.failed, workflow_spec.status)
0585     failures += not check(
0586         "the step that could not be reached is recorded",
0587         workflow_spec.get_parameter(workflow_core.UNCANCELLED_STEPS_PARAM) == [held.step_id],
0588         workflow_spec.get_parameter(workflow_core.UNCANCELLED_STEPS_PARAM),
0589     )
0590 
0591     print("\n=== a later cycle over the same workflow issues no new kills ===")
0592     interface = make_interface(
0593         [],
0594         [FakeStep("broke", WFStepStatus.failed, target_id="48810706"), FakeStep("deriv_phys", WFStepStatus.cancelled, target_id="48810720")],
0595         processed_stats={WFStepStatus.failed: 1, WFStepStatus.cancelled: 1},
0596     )
0597     interface.process_workflow_running(make_workflow())
0598     failures += not check("no kill is sent", interface.step_handler.cancelled_target_ids == [], interface.step_handler.cancelled_target_ids)
0599     failures += not check(
0600         "no step is written",
0601         not [entry for entry in interface.tbif.call_log if entry[0] == "update_workflow_step"],
0602         interface.tbif.call_log,
0603     )
0604 
0605     print("\n=== an error during the cleanup leaves the workflow to the next cycle ===")
0606     interface = make_interface([], [FakeStep("broke", WFStepStatus.failed, target_id="1")], processed_stats={WFStepStatus.failed: 1})
0607     # the first query is the caller's own; the cleanup's re-read is the second
0608     interface.tbif.raise_on_get_steps_call = 2
0609     workflow_spec = make_workflow()
0610     result = interface.process_workflow_running(workflow_spec)
0611     failures += not check("the workflow stays running", workflow_spec.status == WorkflowStatus.running, workflow_spec.status)
0612     failures += not check("nothing is written", interface.tbif.updated_workflows == [], interface.tbif.updated_workflows)
0613     failures += not check("the error is reported", result.success is not True, result.success)
0614 
0615     print("\n=== a root input already parked in waiting_suffice ===")
0616     # Workflow 133's rdo_bkg: it exists and has files, but DDM never closes it. A datum parked
0617     # there before the rule existed is let out here; a new one never gets parked at all.
0618     root_input = FakeData("rdo_bkg", WFDataStatus.waiting_suffice, WFDataType.input)
0619     interface = make_interface([root_input], [])
0620     result = interface.process_data_waiting(root_input)
0621     failures += not check("it becomes done_waited", root_input.status == WFDataStatus.done_waited, root_input.status)
0622     failures += not check("the transition is reported", result.new_status == WFDataStatus.done_waited, result.new_status)
0623     failures += not check("an end time is stamped", root_input.end_time is not None)
0624 
0625     print("\n=== but data produced inside the workflow still waits ===")
0626     for data_type in (WFDataType.mid, WFDataType.output):
0627         produced = FakeData(f"step/{data_type}", WFDataStatus.waiting_suffice, data_type)
0628         make_interface([produced], []).process_data_waiting(produced)
0629         failures += not check(f"{data_type} stays waiting_suffice", produced.status == WFDataStatus.waiting_suffice, produced.status)
0630 
0631     print("\n=== a root input that is not sufficient yet still waits ===")
0632     not_enough = FakeData("rdo_bkg", WFDataStatus.waiting_insuffi, WFDataType.input)
0633     make_interface([not_enough], [], check_status=WFDataTargetCheckStatus.insuffi).process_data_waiting(not_enough)
0634     failures += not check("it stays waiting_insuffi", not_enough.status == WFDataStatus.waiting_insuffi, not_enough.status)
0635 
0636     print("\n=== a closed collection is unchanged by this ===")
0637     closed = FakeData("rdo_bkg", WFDataStatus.waiting_suffice, WFDataType.input)
0638     make_interface([closed], [], check_status=WFDataTargetCheckStatus.complete).process_data_waiting(closed)
0639     failures += not check("complete still means done_waited", closed.status == WFDataStatus.done_waited, closed.status)
0640 
0641     print("\n=== and the step can now finish ===")
0642     # The point of the change: a done root input makes all_inputs_complete true, which is what
0643     # releases workflowHoldup so the task is allowed to finish.
0644     stats = make_interface([], [])._check_all_inputs_of_step(Log(), ["rdo_bkg"], {"rdo_bkg": root_input})
0645     failures += not check("all_inputs_complete is true once it is done", stats["all_inputs_complete"] is True, stats)
0646     still_waiting = FakeData("rdo_bkg", WFDataStatus.waiting_suffice, WFDataType.input)
0647     stats = make_interface([], [])._check_all_inputs_of_step(Log(), ["rdo_bkg"], {"rdo_bkg": still_waiting})
0648     failures += not check("...and was false while it waited", stats["all_inputs_complete"] is False, stats)
0649     failures += not check("...though it was already good enough to start on", stats["all_inputs_sufficient"] is True, stats)
0650 
0651     print("\n=== a root input never enters the waiting path in the first place ===")
0652     # The first check is where it is settled: an open but sufficient collection is called complete,
0653     # so the datum goes checked_complete -> done_skipped and is terminal straight away.
0654     fresh = FakeData("rdo_bkg", WFDataStatus.checking, WFDataType.input)
0655     interface = make_interface([fresh], [], check_status=WFDataTargetCheckStatus.suffice)
0656     interface.process_data_checking(fresh)
0657     failures += not check("checked_complete, not checked_suffice", fresh.status == WFDataStatus.checked_complete, fresh.status)
0658     interface.process_data_checked(fresh)
0659     failures += not check("and then done_skipped", fresh.status == WFDataStatus.done_skipped, fresh.status)
0660 
0661     print("\n=== data produced inside the workflow is unaffected at that check ===")
0662     for data_type in (WFDataType.mid, WFDataType.output):
0663         produced = FakeData(f"step/{data_type}", WFDataStatus.checking, data_type)
0664         make_interface([produced], [], check_status=WFDataTargetCheckStatus.suffice).process_data_checking(produced)
0665         failures += not check(f"{data_type} is still checked_suffice", produced.status == WFDataStatus.checked_suffice, produced.status)
0666 
0667     print("\n=== an insufficient root input is still not complete ===")
0668     thin = FakeData("rdo_bkg", WFDataStatus.checking, WFDataType.input)
0669     make_interface([thin], [], check_status=WFDataTargetCheckStatus.insuffi).process_data_checking(thin)
0670     failures += not check("checked_insuffi is untouched", thin.status == WFDataStatus.checked_insuffi, thin.status)
0671 
0672     print("\n=== a missing root input is still missing ===")
0673     gone = FakeData("rdo_bkg", WFDataStatus.checking, WFDataType.input)
0674     make_interface([gone], [], check_status=WFDataTargetCheckStatus.nonexist).process_data_checking(gone)
0675     failures += not check("checked_nonexist is untouched", gone.status == WFDataStatus.checked_nonexist, gone.status)
0676 
0677     print(f"\n{'ALL CHECKS PASSED' if not failures else f'{failures} CHECK(S) FAILED'}")
0678     return 1 if failures else 0
0679 
0680 
0681 if __name__ == "__main__":
0682     sys.exit(main())