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
0028
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
0107 from pandaserver.workflow.workflow_base import (
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
0119
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
0153
0154
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
0182
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
0196
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
0211
0212
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
0290
0291
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
0301
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
0307
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
0368
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
0431
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
0456
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
0481
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
0493
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
0500
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
0521
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
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
0617
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
0643
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
0653
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())