nextpipe 0.7.4.dev0__tar.gz → 0.8.0.dev0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/PKG-INFO +1 -1
- nextpipe-0.8.0.dev0/nextpipe/__about__.py +1 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/__init__.py +2 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/decorators.py +139 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/flow.py +253 -28
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/threads.py +1 -1
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/uplink.py +4 -2
- nextpipe-0.8.0.dev0/tests/test_cancel.py +138 -0
- nextpipe-0.8.0.dev0/tests/test_on_failure.py +217 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_uplink.py +17 -1
- nextpipe-0.7.4.dev0/nextpipe/__about__.py +0 -1
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.github/workflows/lint.yml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.github/workflows/release.yml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.github/workflows/test.yml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.gitignore +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.nextmv/bump_requirements.sh +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.prettierrc.yml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/.python-version +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/LICENSE.md +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/README.md +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/RELEASE.md +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/config.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/constants.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/graph.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/schema.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe/utils.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/nextpipe.code-workspace +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/pyproject.toml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/.gitignore +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/README.md +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/__init__.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo/.gitignore +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo/README.md +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo/app.yaml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo/main.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo/requirements.txt +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/.gitignore +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/README.md +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/app.yaml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/inputs/data.csv +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/inputs/input.xlsx +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/main.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/requirements.txt +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/deploy/app.yaml +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/deploy/main.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/deploy/requirements.txt +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/appapp.json +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/appapp.json.golden +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/appapp.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/chain.json +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/chain.json.golden +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/chain.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/complex.json +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/complex.json.golden +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/complex.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/fail.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach-2-pred.json +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach-2-pred.json.golden +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach-2-pred.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach.json +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach.json.golden +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/inputs/data.csv +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/inputs/input.xlsx +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/pipelines/multifile.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_flow.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_graph.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_integration.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_optional.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_schema.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_threads.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/tests/test_version.py +0 -0
- {nextpipe-0.7.4.dev0 → nextpipe-0.8.0.dev0}/uv.lock +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "v0.8.0.dev0"
|
|
@@ -6,9 +6,11 @@ from .decorators import app as app
|
|
|
6
6
|
from .decorators import foreach as foreach
|
|
7
7
|
from .decorators import join as join
|
|
8
8
|
from .decorators import needs as needs
|
|
9
|
+
from .decorators import on_failure as on_failure
|
|
9
10
|
from .decorators import optional as optional
|
|
10
11
|
from .decorators import repeat as repeat
|
|
11
12
|
from .decorators import step as step
|
|
13
|
+
from .flow import AppRunError as AppRunError
|
|
12
14
|
from .flow import FlowGraph as FlowGraph
|
|
13
15
|
from .flow import FlowSpec as FlowSpec
|
|
14
16
|
from .schema import AppOption as AppOption
|
|
@@ -18,6 +18,8 @@ Needs
|
|
|
18
18
|
Represents dependencies between steps.
|
|
19
19
|
Optional
|
|
20
20
|
Represents an optional step condition.
|
|
21
|
+
OnFailure
|
|
22
|
+
Represents the failure handling of a step.
|
|
21
23
|
Repeat
|
|
22
24
|
Represents a repeating step.
|
|
23
25
|
Foreach
|
|
@@ -35,6 +37,8 @@ needs
|
|
|
35
37
|
Decorator to mark the predecessors of a step.
|
|
36
38
|
optional
|
|
37
39
|
Decorator to mark a step as optional.
|
|
40
|
+
on_failure
|
|
41
|
+
Decorator to let a step fail without failing the flow.
|
|
38
42
|
repeat
|
|
39
43
|
Decorator to make a step be repeated a number of times.
|
|
40
44
|
foreach
|
|
@@ -190,6 +194,18 @@ class Step:
|
|
|
190
194
|
|
|
191
195
|
return hasattr(self, "optional")
|
|
192
196
|
|
|
197
|
+
def is_on_failure(self):
|
|
198
|
+
"""
|
|
199
|
+
Check if the step may fail without failing the flow.
|
|
200
|
+
|
|
201
|
+
Returns
|
|
202
|
+
-------
|
|
203
|
+
bool
|
|
204
|
+
True if the step has an `on_failure` handler, False otherwise.
|
|
205
|
+
"""
|
|
206
|
+
|
|
207
|
+
return hasattr(self, "on_failure")
|
|
208
|
+
|
|
193
209
|
def skip(self):
|
|
194
210
|
"""
|
|
195
211
|
Check if the step should be skipped.
|
|
@@ -608,6 +624,129 @@ def optional(condition: Callable[..., bool], skip_downstream: bool = False):
|
|
|
608
624
|
return decorator
|
|
609
625
|
|
|
610
626
|
|
|
627
|
+
class OnFailure:
|
|
628
|
+
"""
|
|
629
|
+
Represents the failure handling of a step.
|
|
630
|
+
|
|
631
|
+
This class is used by the `on_failure` decorator to specify the result that
|
|
632
|
+
replaces the result of a step if it fails.
|
|
633
|
+
|
|
634
|
+
Attributes
|
|
635
|
+
----------
|
|
636
|
+
fallback : Any
|
|
637
|
+
The result to use instead, or a callable that receives the error and
|
|
638
|
+
returns it.
|
|
639
|
+
"""
|
|
640
|
+
|
|
641
|
+
def __init__(self, fallback: typing.Any = None):
|
|
642
|
+
"""
|
|
643
|
+
Initialize an OnFailure object.
|
|
644
|
+
|
|
645
|
+
Parameters
|
|
646
|
+
----------
|
|
647
|
+
fallback : Any, optional
|
|
648
|
+
The result to use instead, or a callable that receives the error
|
|
649
|
+
and returns it, by default None.
|
|
650
|
+
"""
|
|
651
|
+
|
|
652
|
+
self.fallback = fallback
|
|
653
|
+
|
|
654
|
+
def resolve(self, error: Exception) -> typing.Any:
|
|
655
|
+
"""
|
|
656
|
+
Determine the result to use for a failed step.
|
|
657
|
+
|
|
658
|
+
Parameters
|
|
659
|
+
----------
|
|
660
|
+
error : Exception
|
|
661
|
+
The error that made the step fail.
|
|
662
|
+
|
|
663
|
+
Returns
|
|
664
|
+
-------
|
|
665
|
+
Any
|
|
666
|
+
The fallback result.
|
|
667
|
+
"""
|
|
668
|
+
|
|
669
|
+
if callable(self.fallback):
|
|
670
|
+
return self.fallback(error)
|
|
671
|
+
return self.fallback
|
|
672
|
+
|
|
673
|
+
def __repr__(self):
|
|
674
|
+
"""
|
|
675
|
+
Return a string representation of the failure handling.
|
|
676
|
+
|
|
677
|
+
Returns
|
|
678
|
+
-------
|
|
679
|
+
str
|
|
680
|
+
A string representation of the failure handling.
|
|
681
|
+
"""
|
|
682
|
+
|
|
683
|
+
return f"OnFailure({self.fallback})"
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def on_failure(fallback: typing.Any = None):
|
|
687
|
+
"""
|
|
688
|
+
Decorator to let a step fail without failing the flow.
|
|
689
|
+
|
|
690
|
+
You can import the `on_failure` decorator directly from `nextpipe`:
|
|
691
|
+
|
|
692
|
+
```python
|
|
693
|
+
from nextpipe import on_failure
|
|
694
|
+
```
|
|
695
|
+
|
|
696
|
+
By default, a failing step fails the whole flow. With this decorator, a
|
|
697
|
+
failure (e.g., an exception or a failed app run) only fails the step
|
|
698
|
+
itself: it has the status `failed`, the flow continues and its successors
|
|
699
|
+
receive the fallback as its result. If the step has multiple nodes (e.g.,
|
|
700
|
+
`foreach`), this applies to each node separately.
|
|
701
|
+
|
|
702
|
+
Parameters
|
|
703
|
+
----------
|
|
704
|
+
fallback : Any, optional
|
|
705
|
+
The result to use instead of the result of the failed step. If it is
|
|
706
|
+
callable, it is called with the error (e.g., an `AppRunError` for a
|
|
707
|
+
failed app run) and its return value is used. By default, None.
|
|
708
|
+
|
|
709
|
+
Example
|
|
710
|
+
-------
|
|
711
|
+
In this example the flow still produces an output if the solver app fails.
|
|
712
|
+
|
|
713
|
+
```
|
|
714
|
+
from nextpipe import FlowSpec, app, log, needs, on_failure, step
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
class Flow(FlowSpec):
|
|
718
|
+
@step
|
|
719
|
+
def prepare(data: dict) -> dict:
|
|
720
|
+
return data
|
|
721
|
+
|
|
722
|
+
@on_failure(fallback=lambda error: {"error": str(error)})
|
|
723
|
+
@app(app_id="solver")
|
|
724
|
+
@needs(predecessors=[prepare])
|
|
725
|
+
@step
|
|
726
|
+
def solve() -> None:
|
|
727
|
+
pass
|
|
728
|
+
|
|
729
|
+
@needs(predecessors=[solve])
|
|
730
|
+
@step
|
|
731
|
+
def report(result: dict) -> dict:
|
|
732
|
+
if "error" in result:
|
|
733
|
+
return {"status": "failed", "reason": result["error"]}
|
|
734
|
+
return {"status": "ok", "solution": result["solution"]}
|
|
735
|
+
|
|
736
|
+
|
|
737
|
+
flow = Flow("DecisionFlow", {"orders": [1, 2, 3]})
|
|
738
|
+
flow.run()
|
|
739
|
+
log(flow.get_result(flow.report))
|
|
740
|
+
```
|
|
741
|
+
"""
|
|
742
|
+
|
|
743
|
+
def decorator(function):
|
|
744
|
+
function.step.on_failure = OnFailure(fallback)
|
|
745
|
+
return function
|
|
746
|
+
|
|
747
|
+
return decorator
|
|
748
|
+
|
|
749
|
+
|
|
611
750
|
class Repeat:
|
|
612
751
|
"""
|
|
613
752
|
Represents a repeating step.
|
|
@@ -18,6 +18,8 @@ StepVisitor
|
|
|
18
18
|
AST visitor for finding step functions in a flow class.
|
|
19
19
|
Runner
|
|
20
20
|
Handles the execution of a flow graph.
|
|
21
|
+
AppRunError
|
|
22
|
+
Raised when the run of an app step does not succeed.
|
|
21
23
|
|
|
22
24
|
The module also defines status constants used throughout pipeline execution.
|
|
23
25
|
"""
|
|
@@ -32,6 +34,7 @@ import os
|
|
|
32
34
|
import pathlib
|
|
33
35
|
import random
|
|
34
36
|
import shutil
|
|
37
|
+
import signal
|
|
35
38
|
import tempfile
|
|
36
39
|
import threading
|
|
37
40
|
import time
|
|
@@ -62,6 +65,54 @@ STATUS_FAILED = "failed"
|
|
|
62
65
|
STATUS_SKIPPED = "skipped"
|
|
63
66
|
"""Status constant indicating a skipped step or node."""
|
|
64
67
|
|
|
68
|
+
SUB_RUN_CANCEL_TIMEOUT = 5.0
|
|
69
|
+
"""
|
|
70
|
+
Maximum time in seconds to wait for sub-run cancel requests when the flow is aborted.
|
|
71
|
+
|
|
72
|
+
When a run is canceled on the Nextmv platform, the workflow process is
|
|
73
|
+
interrupted and killed after a grace period of 10 seconds. This timeout, plus
|
|
74
|
+
the final flow update sent afterwards, must fit within that grace period.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AppRunError(Exception):
|
|
79
|
+
"""
|
|
80
|
+
Raised when the run of an app step does not succeed.
|
|
81
|
+
|
|
82
|
+
You can import the `AppRunError` class directly from `nextpipe`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from nextpipe import AppRunError
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Attributes
|
|
89
|
+
----------
|
|
90
|
+
step_id : str
|
|
91
|
+
The ID of the node of the app step.
|
|
92
|
+
app_id : str
|
|
93
|
+
The ID of the application that was run.
|
|
94
|
+
run_id : str
|
|
95
|
+
The ID of the run.
|
|
96
|
+
status : str
|
|
97
|
+
The final status of the run (e.g., `failed` or `canceled`).
|
|
98
|
+
error : str
|
|
99
|
+
The error message of the run, if any.
|
|
100
|
+
console_url : str
|
|
101
|
+
The URL of the run in the Nextmv Console.
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, step_id: str, app_id: str, run_id: str, status: str, error: str, console_url: str):
|
|
105
|
+
self.step_id = step_id
|
|
106
|
+
self.app_id = app_id
|
|
107
|
+
self.run_id = run_id
|
|
108
|
+
self.status = status
|
|
109
|
+
self.error = error
|
|
110
|
+
self.console_url = console_url
|
|
111
|
+
message = f"run {run_id} of app {app_id} {status}"
|
|
112
|
+
if error:
|
|
113
|
+
message += f": {error}"
|
|
114
|
+
super().__init__(f"{message} (see {console_url})")
|
|
115
|
+
|
|
65
116
|
|
|
66
117
|
class FlowStep:
|
|
67
118
|
"""
|
|
@@ -735,6 +786,8 @@ class Runner:
|
|
|
735
786
|
Whether the flow has failed.
|
|
736
787
|
fail_reason : str or None
|
|
737
788
|
Reason for the flow failure, if any.
|
|
789
|
+
fail_error : Exception or None
|
|
790
|
+
Error that made the flow fail, if any.
|
|
738
791
|
lock_fail : threading.Lock
|
|
739
792
|
Lock for updating fail state.
|
|
740
793
|
lock_running : threading.Lock
|
|
@@ -771,6 +824,7 @@ class Runner:
|
|
|
771
824
|
self.node_idxs = {}
|
|
772
825
|
self.fail = False
|
|
773
826
|
self.fail_reason = None
|
|
827
|
+
self.fail_error = None
|
|
774
828
|
self.lock_fail = threading.Lock()
|
|
775
829
|
self.lock_running = threading.Lock()
|
|
776
830
|
|
|
@@ -882,11 +936,24 @@ class Runner:
|
|
|
882
936
|
failed = job.error or reference.error
|
|
883
937
|
reference.status = STATUS_SUCCEEDED if not failed else STATUS_FAILED
|
|
884
938
|
reference.result = job.result
|
|
939
|
+
error = job.error
|
|
940
|
+
if error:
|
|
941
|
+
reference.error = str(error)
|
|
942
|
+
definition = reference.parent.definition
|
|
943
|
+
if definition.is_on_failure() and not reference.cancel:
|
|
944
|
+
# The step may fail, continue with the fallback as its result
|
|
945
|
+
try:
|
|
946
|
+
reference.result = definition.on_failure.resolve(error)
|
|
947
|
+
utils.log_internal(f"Node {reference.id} failed, continuing with fallback: {error}")
|
|
948
|
+
error = None
|
|
949
|
+
except Exception as e:
|
|
950
|
+
error = e
|
|
885
951
|
# Check if the job failed and mark the flow as failed if it did
|
|
886
952
|
with self.lock_fail:
|
|
887
|
-
if
|
|
953
|
+
if error and not self.fail:
|
|
888
954
|
self.fail = True
|
|
889
|
-
self.fail_reason = f"Step {reference.parent.definition.get_id()} failed: {
|
|
955
|
+
self.fail_reason = f"Step {reference.parent.definition.get_id()} failed: {error}"
|
|
956
|
+
self.fail_error = error
|
|
890
957
|
# Mark the node as done (and its parent if all nodes are done)
|
|
891
958
|
reference.done = True
|
|
892
959
|
with reference.parent.lock:
|
|
@@ -964,8 +1031,9 @@ class Runner:
|
|
|
964
1031
|
|
|
965
1032
|
polling_options = copy.deepcopy(app_step.polling_options)
|
|
966
1033
|
polling_options.initial_delay = random.uniform(0, 5)
|
|
967
|
-
|
|
968
|
-
|
|
1034
|
+
# Always stop polling when the node gets cancelled, in addition to any user-defined stop condition.
|
|
1035
|
+
user_stop = polling_options.stop
|
|
1036
|
+
polling_options.stop = lambda: node.cancel or (user_stop is not None and user_stop())
|
|
969
1037
|
|
|
970
1038
|
run_kwargs: dict[str, Any] = {
|
|
971
1039
|
"options": options,
|
|
@@ -1004,6 +1072,9 @@ class Runner:
|
|
|
1004
1072
|
utils.log_internal(f"Started app step {node.id} run, find it at {console_url}")
|
|
1005
1073
|
node.run_id = run_id
|
|
1006
1074
|
update_dag() # Update node with run_id before polling
|
|
1075
|
+
if node.cancel:
|
|
1076
|
+
# The flow was aborted while the run was being started, cancel it right away.
|
|
1077
|
+
Runner.__cancel_app_run(node, app)
|
|
1007
1078
|
|
|
1008
1079
|
if app_step.emit_live_logs:
|
|
1009
1080
|
utils.log_internal(f"Emitting live logs for app step {node.id} ...")
|
|
@@ -1013,7 +1084,25 @@ class Runner:
|
|
|
1013
1084
|
log_func=lambda msg: utils.log(message=msg.log, step_name=node.id),
|
|
1014
1085
|
)
|
|
1015
1086
|
|
|
1016
|
-
|
|
1087
|
+
def polling_func() -> tuple[Any, bool]:
|
|
1088
|
+
information = app.run_information(run_id=run_id)
|
|
1089
|
+
return information, information.metadata.run_is_finalized()
|
|
1090
|
+
|
|
1091
|
+
information = nextmv.poll(polling_options=polling_options, polling_func=polling_func)
|
|
1092
|
+
if information is None:
|
|
1093
|
+
raise RuntimeError(f"Stopped waiting for app step {node.id} run {run_id}")
|
|
1094
|
+
# Only fetch the result of succeeded runs, failed ones may not have any output
|
|
1095
|
+
status = information.metadata.status_v2
|
|
1096
|
+
if status != nextmv.StatusV2.succeeded:
|
|
1097
|
+
raise AppRunError(
|
|
1098
|
+
step_id=node.id,
|
|
1099
|
+
app_id=app_step.app_id,
|
|
1100
|
+
run_id=run_id,
|
|
1101
|
+
status=getattr(status, "value", str(status)),
|
|
1102
|
+
error=information.metadata.error,
|
|
1103
|
+
console_url=console_url,
|
|
1104
|
+
)
|
|
1105
|
+
return app.run_result(run_id=run_id, output_dir_path=temp_dir)
|
|
1017
1106
|
|
|
1018
1107
|
utils.log_internal(f"Bypassing app step {node.id} run, using bypass result")
|
|
1019
1108
|
|
|
@@ -1232,31 +1321,126 @@ class Runner:
|
|
|
1232
1321
|
reference=node,
|
|
1233
1322
|
)
|
|
1234
1323
|
|
|
1235
|
-
|
|
1324
|
+
@staticmethod
|
|
1325
|
+
def __cancel_app_run(node: FlowNode, app: Application) -> None:
|
|
1236
1326
|
"""
|
|
1237
|
-
|
|
1327
|
+
Request the cancellation of the remote run of an app step node.
|
|
1238
1328
|
|
|
1239
|
-
This
|
|
1240
|
-
in the correct order based on dependencies, and handles failures.
|
|
1329
|
+
This is best effort: failures are logged, but not raised.
|
|
1241
1330
|
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1331
|
+
Parameters
|
|
1332
|
+
----------
|
|
1333
|
+
node : FlowNode
|
|
1334
|
+
The node whose run should be cancelled.
|
|
1335
|
+
app : Application
|
|
1336
|
+
The application the run belongs to.
|
|
1246
1337
|
"""
|
|
1247
1338
|
|
|
1248
|
-
# Start communicating updates to the platform
|
|
1249
1339
|
try:
|
|
1250
|
-
|
|
1251
|
-
|
|
1340
|
+
app.cancel_run(node.run_id)
|
|
1341
|
+
utils.log_internal(f"Requested cancellation of app step {node.id} run {node.run_id}")
|
|
1252
1342
|
except Exception as e:
|
|
1253
|
-
|
|
1254
|
-
utils.log_internal(f"Failed to update graph with platform: {e}")
|
|
1343
|
+
utils.log_internal(f"Failed to cancel app step {node.id} run {node.run_id}: {e}")
|
|
1255
1344
|
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1345
|
+
def __cancel_running(self, running_steps: set[FlowStep]) -> None:
|
|
1346
|
+
"""
|
|
1347
|
+
Cancel all nodes of the given running steps.
|
|
1348
|
+
|
|
1349
|
+
Flags the nodes as cancelled (which stops polling for their results) and
|
|
1350
|
+
requests the cancellation of the remote runs of app steps that are still
|
|
1351
|
+
in progress. The cancel requests are sent in parallel and waited for at
|
|
1352
|
+
most `SUB_RUN_CANCEL_TIMEOUT` seconds in total.
|
|
1353
|
+
|
|
1354
|
+
Parameters
|
|
1355
|
+
----------
|
|
1356
|
+
running_steps : set[FlowStep]
|
|
1357
|
+
The steps that are currently running.
|
|
1358
|
+
"""
|
|
1359
|
+
|
|
1360
|
+
cancel_threads: list[threading.Thread] = []
|
|
1361
|
+
for step in running_steps:
|
|
1362
|
+
for node in step.nodes:
|
|
1363
|
+
node.cancel = True
|
|
1364
|
+
node.status = STATUS_FAILED
|
|
1365
|
+
if node.done or node.run_id is None or not step.definition.is_app():
|
|
1366
|
+
continue
|
|
1367
|
+
app = Application(client=self.spec.client, id=step.definition.app.app_id)
|
|
1368
|
+
thread = threading.Thread(
|
|
1369
|
+
target=Runner.__cancel_app_run,
|
|
1370
|
+
args=(node, app),
|
|
1371
|
+
name=f"{utils.THREAD_NAME_PREFIX}cancel-{node.id}",
|
|
1372
|
+
daemon=True,
|
|
1373
|
+
)
|
|
1374
|
+
thread.start()
|
|
1375
|
+
cancel_threads.append(thread)
|
|
1376
|
+
|
|
1377
|
+
deadline = time.monotonic() + SUB_RUN_CANCEL_TIMEOUT
|
|
1378
|
+
for thread in cancel_threads:
|
|
1379
|
+
thread.join(timeout=max(0.0, deadline - time.monotonic()))
|
|
1380
|
+
|
|
1381
|
+
@staticmethod
|
|
1382
|
+
def __install_sigterm_handler() -> Any:
|
|
1383
|
+
"""
|
|
1384
|
+
Install a SIGTERM handler that raises `SystemExit`.
|
|
1385
|
+
|
|
1386
|
+
This allows handling a termination request the same way as a keyboard
|
|
1387
|
+
interrupt, i.e., by cancelling the running steps. Signal handlers can only
|
|
1388
|
+
be installed from the main thread, otherwise this is a no-op.
|
|
1389
|
+
|
|
1390
|
+
Returns
|
|
1391
|
+
-------
|
|
1392
|
+
Any
|
|
1393
|
+
The previous SIGTERM handler, or None if no handler was installed.
|
|
1394
|
+
"""
|
|
1395
|
+
|
|
1396
|
+
if threading.current_thread() is not threading.main_thread():
|
|
1397
|
+
return None
|
|
1398
|
+
|
|
1399
|
+
def handler(signum, frame):
|
|
1400
|
+
raise SystemExit(128 + signum)
|
|
1401
|
+
|
|
1402
|
+
return signal.signal(signal.SIGTERM, handler)
|
|
1403
|
+
|
|
1404
|
+
@staticmethod
|
|
1405
|
+
def __restore_sigterm_handler(previous_handler: Any) -> None:
|
|
1406
|
+
"""
|
|
1407
|
+
Restore the SIGTERM handler that was active before running the flow.
|
|
1408
|
+
|
|
1409
|
+
Parameters
|
|
1410
|
+
----------
|
|
1411
|
+
previous_handler : Any
|
|
1412
|
+
The previous handler as returned by `__install_sigterm_handler`.
|
|
1413
|
+
"""
|
|
1414
|
+
|
|
1415
|
+
if previous_handler is not None:
|
|
1416
|
+
signal.signal(signal.SIGTERM, previous_handler)
|
|
1417
|
+
|
|
1418
|
+
def __run_steps(
|
|
1419
|
+
self,
|
|
1420
|
+
open_steps: set[FlowStep],
|
|
1421
|
+
running_steps: set[FlowStep],
|
|
1422
|
+
closed_steps: set[FlowStep],
|
|
1423
|
+
) -> None:
|
|
1424
|
+
"""
|
|
1425
|
+
Execute the steps of the flow until all of them are done.
|
|
1426
|
+
|
|
1427
|
+
The given sets are updated in place, so that the caller can react to
|
|
1428
|
+
interruptions (e.g., cancel the steps still running).
|
|
1429
|
+
|
|
1430
|
+
Parameters
|
|
1431
|
+
----------
|
|
1432
|
+
open_steps : set[FlowStep]
|
|
1433
|
+
Steps that are ready to be considered for execution.
|
|
1434
|
+
running_steps : set[FlowStep]
|
|
1435
|
+
Steps that are currently running.
|
|
1436
|
+
closed_steps : set[FlowStep]
|
|
1437
|
+
Steps that are done.
|
|
1438
|
+
|
|
1439
|
+
Raises
|
|
1440
|
+
------
|
|
1441
|
+
RuntimeError
|
|
1442
|
+
If the flow execution fails.
|
|
1443
|
+
"""
|
|
1260
1444
|
|
|
1261
1445
|
# Run the steps in parallel
|
|
1262
1446
|
while open_steps or running_steps:
|
|
@@ -1315,16 +1499,57 @@ class Runner:
|
|
|
1315
1499
|
# Raise an exception if the flow failed
|
|
1316
1500
|
with self.lock_fail:
|
|
1317
1501
|
if self.fail:
|
|
1318
|
-
# Issue cancel to all nodes
|
|
1319
|
-
|
|
1320
|
-
for node in step.nodes:
|
|
1321
|
-
node.cancel = True
|
|
1322
|
-
node.status = STATUS_FAILED
|
|
1502
|
+
# Issue cancel to all nodes (including their remote runs)
|
|
1503
|
+
self.__cancel_running(running_steps)
|
|
1323
1504
|
# Submitting the final state and terminating uplink causes the last
|
|
1324
1505
|
# update to be send to the platform (reflecting the final state).
|
|
1325
1506
|
self.uplink.submit_update(self.graph._to_uplink_dto())
|
|
1326
1507
|
self.uplink.terminate() # This will issue the final update.
|
|
1327
|
-
|
|
1508
|
+
# Failed app runs are fully described by their message, other
|
|
1509
|
+
# errors keep their traceback (e.g., to debug the step function).
|
|
1510
|
+
cause = None if isinstance(self.fail_error, AppRunError) else self.fail_error
|
|
1511
|
+
raise RuntimeError(f"Flow failed: {self.fail_reason}") from cause
|
|
1512
|
+
|
|
1513
|
+
def run(self):
|
|
1514
|
+
"""
|
|
1515
|
+
Run the flow.
|
|
1516
|
+
|
|
1517
|
+
This method starts the uplink communication, executes the flow steps
|
|
1518
|
+
in the correct order based on dependencies, and handles failures.
|
|
1519
|
+
|
|
1520
|
+
Raises
|
|
1521
|
+
------
|
|
1522
|
+
RuntimeError
|
|
1523
|
+
If the flow execution fails.
|
|
1524
|
+
"""
|
|
1525
|
+
|
|
1526
|
+
# Start communicating updates to the platform
|
|
1527
|
+
try:
|
|
1528
|
+
self.uplink.submit_update(self.graph._to_uplink_dto())
|
|
1529
|
+
self.uplink.run_async()
|
|
1530
|
+
except Exception as e:
|
|
1531
|
+
self.uplink.terminate()
|
|
1532
|
+
utils.log_internal(f"Failed to update graph with platform: {e}")
|
|
1533
|
+
|
|
1534
|
+
# Start running the flow
|
|
1535
|
+
open_steps: set[FlowStep] = set(self.graph.start_steps)
|
|
1536
|
+
running_steps: set[FlowStep] = set()
|
|
1537
|
+
closed_steps: set[FlowStep] = set()
|
|
1538
|
+
|
|
1539
|
+
# Translate SIGTERM (e.g., a cancelled run on the platform) into an
|
|
1540
|
+
# exception, so that it is handled like SIGINT (ctrl+c).
|
|
1541
|
+
previous_sigterm_handler = Runner.__install_sigterm_handler()
|
|
1542
|
+
|
|
1543
|
+
try:
|
|
1544
|
+
self.__run_steps(open_steps, running_steps, closed_steps)
|
|
1545
|
+
except (KeyboardInterrupt, SystemExit):
|
|
1546
|
+
utils.log_internal("Flow was interrupted, cancelling running steps")
|
|
1547
|
+
self.__cancel_running(running_steps)
|
|
1548
|
+
self.uplink.submit_update(self.graph._to_uplink_dto())
|
|
1549
|
+
self.uplink.terminate()
|
|
1550
|
+
raise
|
|
1551
|
+
finally:
|
|
1552
|
+
Runner.__restore_sigterm_handler(previous_sigterm_handler)
|
|
1328
1553
|
|
|
1329
1554
|
# Terminate uplink
|
|
1330
1555
|
self.uplink.terminate()
|
|
@@ -225,8 +225,8 @@ class Pool:
|
|
|
225
225
|
job.start_callback(job)
|
|
226
226
|
job.run()
|
|
227
227
|
except Exception as e:
|
|
228
|
+
# Keep the error on the job, it is up to the done callback to handle it
|
|
228
229
|
job.error = e
|
|
229
|
-
raise RuntimeError(f"Error in thread {thread_id}: {e}") from e
|
|
230
230
|
finally:
|
|
231
231
|
with self.lock:
|
|
232
232
|
self.running.pop(thread_id, None)
|
|
@@ -286,6 +286,7 @@ class UplinkClient:
|
|
|
286
286
|
self.changed = False
|
|
287
287
|
self._terminate = False
|
|
288
288
|
self._terminated = False
|
|
289
|
+
self._wake = threading.Event()
|
|
289
290
|
self._updates_failed = 0
|
|
290
291
|
|
|
291
292
|
def _post_node_update(self):
|
|
@@ -375,8 +376,8 @@ class UplinkClient:
|
|
|
375
376
|
self._terminate = True
|
|
376
377
|
else:
|
|
377
378
|
self._updates_failed = 0
|
|
378
|
-
# Sleep
|
|
379
|
-
|
|
379
|
+
# Sleep (woken up early on termination)
|
|
380
|
+
self._wake.wait(UPDATE_INTERVAL)
|
|
380
381
|
|
|
381
382
|
# Signal termination
|
|
382
383
|
self._terminated = True
|
|
@@ -399,6 +400,7 @@ class UplinkClient:
|
|
|
399
400
|
|
|
400
401
|
# Terminate the client
|
|
401
402
|
self._terminate = True
|
|
403
|
+
self._wake.set()
|
|
402
404
|
while not self._terminated:
|
|
403
405
|
time.sleep(0.1)
|
|
404
406
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import time
|
|
6
|
+
import unittest
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
from nextpipe import FlowSpec, app, needs, step
|
|
10
|
+
from nextpipe.flow import STATUS_FAILED
|
|
11
|
+
|
|
12
|
+
# Add the parent directory to the sys.path to allow imports from the main package. This
|
|
13
|
+
# is meant to help VS Code testing features.
|
|
14
|
+
sys.path.append(os.path.dirname(sys.path[0]))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class _FakeApplication:
|
|
18
|
+
"""Stand-in for a nextmv Application whose runs only end when cancelled."""
|
|
19
|
+
|
|
20
|
+
instances: list["_FakeApplication"] = []
|
|
21
|
+
lock = threading.Lock()
|
|
22
|
+
started = threading.Event()
|
|
23
|
+
fail_cancel = False
|
|
24
|
+
|
|
25
|
+
def __init__(self, client=None, id=None, **kwargs):
|
|
26
|
+
self.id = id
|
|
27
|
+
self.default_instance_id = None
|
|
28
|
+
self.cancelled: list[str] = []
|
|
29
|
+
with _FakeApplication.lock:
|
|
30
|
+
_FakeApplication.instances.append(self)
|
|
31
|
+
|
|
32
|
+
@classmethod
|
|
33
|
+
def reset(cls):
|
|
34
|
+
cls.instances = []
|
|
35
|
+
cls.started = threading.Event()
|
|
36
|
+
cls.fail_cancel = False
|
|
37
|
+
|
|
38
|
+
@classmethod
|
|
39
|
+
def cancelled_runs(cls) -> list[str]:
|
|
40
|
+
with cls.lock:
|
|
41
|
+
return sorted(r for a in cls.instances for r in a.cancelled)
|
|
42
|
+
|
|
43
|
+
def new_run(self, **kwargs) -> str:
|
|
44
|
+
_FakeApplication.started.set()
|
|
45
|
+
return f"run-{self.id}"
|
|
46
|
+
|
|
47
|
+
def run_information(self, run_id):
|
|
48
|
+
# The run never finishes on its own, so polling only ends on the stop condition.
|
|
49
|
+
time.sleep(0.01)
|
|
50
|
+
return mock.Mock(metadata=mock.Mock(run_is_finalized=lambda: False))
|
|
51
|
+
|
|
52
|
+
def cancel_run(self, run_id: str) -> None:
|
|
53
|
+
if _FakeApplication.fail_cancel:
|
|
54
|
+
raise RuntimeError("cancel failed")
|
|
55
|
+
with _FakeApplication.lock:
|
|
56
|
+
self.cancelled.append(run_id)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SubRunFlow(FlowSpec):
|
|
60
|
+
@step
|
|
61
|
+
def prepare(data: dict) -> dict:
|
|
62
|
+
return data
|
|
63
|
+
|
|
64
|
+
@app(app_id="solver-a")
|
|
65
|
+
@needs(predecessors=[prepare])
|
|
66
|
+
@step
|
|
67
|
+
def solve_a() -> None:
|
|
68
|
+
pass
|
|
69
|
+
|
|
70
|
+
@app(app_id="solver-b")
|
|
71
|
+
@needs(predecessors=[prepare])
|
|
72
|
+
@step
|
|
73
|
+
def solve_b() -> None:
|
|
74
|
+
pass
|
|
75
|
+
|
|
76
|
+
@needs(predecessors=[prepare])
|
|
77
|
+
@step
|
|
78
|
+
def broken(data: dict) -> None:
|
|
79
|
+
if data.get("fail"):
|
|
80
|
+
_FakeApplication.started.wait(5)
|
|
81
|
+
time.sleep(0.2) # Give both app steps the chance to start their runs
|
|
82
|
+
raise ValueError("boom")
|
|
83
|
+
_FakeApplication.started.wait(5)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _interrupt_when_started(sig: int):
|
|
87
|
+
"""Send the given signal to this process once the sub-runs have been started."""
|
|
88
|
+
|
|
89
|
+
def target():
|
|
90
|
+
_FakeApplication.started.wait(5)
|
|
91
|
+
time.sleep(0.2)
|
|
92
|
+
os.kill(os.getpid(), sig)
|
|
93
|
+
|
|
94
|
+
threading.Thread(target=target, daemon=True).start()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@mock.patch("nextpipe.flow.Application", _FakeApplication)
|
|
98
|
+
class TestCascadingCancel(unittest.TestCase):
|
|
99
|
+
def setUp(self):
|
|
100
|
+
_FakeApplication.reset()
|
|
101
|
+
|
|
102
|
+
def test_failure_cancels_sub_runs(self):
|
|
103
|
+
flow = SubRunFlow("SubRun", {"fail": True})
|
|
104
|
+
with self.assertRaises(RuntimeError):
|
|
105
|
+
flow.run()
|
|
106
|
+
self.assertEqual(_FakeApplication.cancelled_runs(), ["run-solver-a", "run-solver-b"])
|
|
107
|
+
|
|
108
|
+
def test_keyboard_interrupt_cancels_sub_runs(self):
|
|
109
|
+
flow = SubRunFlow("SubRun", {})
|
|
110
|
+
_interrupt_when_started(signal.SIGINT)
|
|
111
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
112
|
+
flow.run()
|
|
113
|
+
self.assertEqual(_FakeApplication.cancelled_runs(), ["run-solver-a", "run-solver-b"])
|
|
114
|
+
for node in flow.graph.get_step(SubRunFlow.solve_a.step).nodes:
|
|
115
|
+
self.assertTrue(node.cancel)
|
|
116
|
+
self.assertEqual(node.status, STATUS_FAILED)
|
|
117
|
+
|
|
118
|
+
def test_sigterm_cancels_sub_runs(self):
|
|
119
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
120
|
+
flow = SubRunFlow("SubRun", {})
|
|
121
|
+
_interrupt_when_started(signal.SIGTERM)
|
|
122
|
+
with self.assertRaises(SystemExit):
|
|
123
|
+
flow.run()
|
|
124
|
+
self.assertEqual(_FakeApplication.cancelled_runs(), ["run-solver-a", "run-solver-b"])
|
|
125
|
+
# The previous SIGTERM handler is restored after the flow ran
|
|
126
|
+
self.assertIs(signal.getsignal(signal.SIGTERM), previous)
|
|
127
|
+
|
|
128
|
+
def test_failing_cancel_request_does_not_mask_interrupt(self):
|
|
129
|
+
_FakeApplication.fail_cancel = True
|
|
130
|
+
flow = SubRunFlow("SubRun", {})
|
|
131
|
+
_interrupt_when_started(signal.SIGINT)
|
|
132
|
+
with self.assertRaises(KeyboardInterrupt):
|
|
133
|
+
flow.run()
|
|
134
|
+
self.assertEqual(_FakeApplication.cancelled_runs(), [])
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
unittest.main()
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import io
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
import threading
|
|
5
|
+
import unittest
|
|
6
|
+
from contextlib import redirect_stderr
|
|
7
|
+
from unittest import mock
|
|
8
|
+
|
|
9
|
+
import nextmv
|
|
10
|
+
|
|
11
|
+
from nextpipe import AppRunError, FlowSpec, app, foreach, join, needs, on_failure, step
|
|
12
|
+
from nextpipe.flow import STATUS_FAILED, STATUS_SUCCEEDED
|
|
13
|
+
|
|
14
|
+
# Add the parent directory to the sys.path to allow imports from the main package. This
|
|
15
|
+
# is meant to help VS Code testing features.
|
|
16
|
+
sys.path.append(os.path.dirname(sys.path[0]))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _FakeApplication:
|
|
20
|
+
"""Stand-in for a nextmv Application whose runs end with a given status."""
|
|
21
|
+
|
|
22
|
+
status = nextmv.StatusV2.failed
|
|
23
|
+
error = "solver crashed"
|
|
24
|
+
|
|
25
|
+
def __init__(self, client=None, id=None, **kwargs):
|
|
26
|
+
self.id = id
|
|
27
|
+
self.default_instance_id = None
|
|
28
|
+
|
|
29
|
+
def new_run(self, **kwargs) -> str:
|
|
30
|
+
return f"run-{self.id}"
|
|
31
|
+
|
|
32
|
+
def run_information(self, run_id):
|
|
33
|
+
status = _FakeApplication.status
|
|
34
|
+
metadata = mock.Mock(status_v2=status, error=_FakeApplication.error, run_is_finalized=lambda: True)
|
|
35
|
+
return mock.Mock(metadata=metadata)
|
|
36
|
+
|
|
37
|
+
def run_result(self, run_id, output_dir_path):
|
|
38
|
+
return mock.Mock(output={"solution": "ok"}, metadata=mock.Mock(error=""))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class FallbackFlow(FlowSpec):
|
|
42
|
+
@step
|
|
43
|
+
def prepare(data: dict) -> dict:
|
|
44
|
+
return data
|
|
45
|
+
|
|
46
|
+
@on_failure(fallback=lambda error: {"error": str(error)})
|
|
47
|
+
@needs(predecessors=[prepare])
|
|
48
|
+
@step
|
|
49
|
+
def broken(data: dict) -> dict:
|
|
50
|
+
raise ValueError("boom")
|
|
51
|
+
|
|
52
|
+
@needs(predecessors=[broken])
|
|
53
|
+
@step
|
|
54
|
+
def report(result: dict) -> dict:
|
|
55
|
+
return {"report": result}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DefaultFallbackFlow(FlowSpec):
|
|
59
|
+
@on_failure()
|
|
60
|
+
@step
|
|
61
|
+
def broken(data: dict) -> dict:
|
|
62
|
+
raise ValueError("boom")
|
|
63
|
+
|
|
64
|
+
@needs(predecessors=[broken])
|
|
65
|
+
@step
|
|
66
|
+
def report(result: dict | None) -> str:
|
|
67
|
+
return "no result" if result is None else "result"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class FailingFallbackFlow(FlowSpec):
|
|
71
|
+
@on_failure(fallback=lambda error: 1 / 0)
|
|
72
|
+
@step
|
|
73
|
+
def broken(data: dict) -> dict:
|
|
74
|
+
raise ValueError("boom")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class ForeachFallbackFlow(FlowSpec):
|
|
78
|
+
@foreach()
|
|
79
|
+
@step
|
|
80
|
+
def split(data: dict) -> list[int]:
|
|
81
|
+
return [1, 2, 3]
|
|
82
|
+
|
|
83
|
+
@on_failure(fallback=-1)
|
|
84
|
+
@needs(predecessors=[split])
|
|
85
|
+
@step
|
|
86
|
+
def work(item: int) -> int:
|
|
87
|
+
if item == 2:
|
|
88
|
+
raise ValueError("boom")
|
|
89
|
+
return item * 10
|
|
90
|
+
|
|
91
|
+
@join()
|
|
92
|
+
@needs(predecessors=[work])
|
|
93
|
+
@step
|
|
94
|
+
def collect(results: list[list[int]]) -> list[int]:
|
|
95
|
+
return [r[0] for r in results]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class AppFlow(FlowSpec):
|
|
99
|
+
@step
|
|
100
|
+
def prepare(data: dict) -> dict:
|
|
101
|
+
return data
|
|
102
|
+
|
|
103
|
+
@app(app_id="solver")
|
|
104
|
+
@needs(predecessors=[prepare])
|
|
105
|
+
@step
|
|
106
|
+
def solve() -> None:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
@needs(predecessors=[solve])
|
|
110
|
+
@step
|
|
111
|
+
def report(result: dict) -> dict:
|
|
112
|
+
return result
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class AppFallbackFlow(FlowSpec):
|
|
116
|
+
@step
|
|
117
|
+
def prepare(data: dict) -> dict:
|
|
118
|
+
return data
|
|
119
|
+
|
|
120
|
+
@on_failure(fallback=lambda error: error)
|
|
121
|
+
@app(app_id="solver")
|
|
122
|
+
@needs(predecessors=[prepare])
|
|
123
|
+
@step
|
|
124
|
+
def solve() -> None:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
@needs(predecessors=[solve])
|
|
128
|
+
@step
|
|
129
|
+
def report(result) -> object:
|
|
130
|
+
return result
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class TestOnFailure(unittest.TestCase):
|
|
134
|
+
def test_fallback_is_passed_to_successors(self):
|
|
135
|
+
flow = FallbackFlow("Fallback", {})
|
|
136
|
+
flow.run()
|
|
137
|
+
self.assertEqual(flow.get_result(flow.report), {"report": {"error": "boom"}})
|
|
138
|
+
node = flow.graph.get_step(FallbackFlow.broken.step).nodes[0]
|
|
139
|
+
self.assertEqual(node.status, STATUS_FAILED)
|
|
140
|
+
self.assertEqual(node.error, "boom")
|
|
141
|
+
|
|
142
|
+
def test_handled_failure_does_not_print_traceback(self):
|
|
143
|
+
unhandled = []
|
|
144
|
+
previous_hook = threading.excepthook
|
|
145
|
+
threading.excepthook = lambda args: unhandled.append(args.exc_value)
|
|
146
|
+
try:
|
|
147
|
+
stderr = io.StringIO()
|
|
148
|
+
with redirect_stderr(stderr):
|
|
149
|
+
FallbackFlow("Fallback", {}).run()
|
|
150
|
+
finally:
|
|
151
|
+
threading.excepthook = previous_hook
|
|
152
|
+
self.assertEqual(unhandled, [])
|
|
153
|
+
self.assertNotIn("Traceback", stderr.getvalue())
|
|
154
|
+
self.assertIn("Node broken_0 failed, continuing with fallback: boom", stderr.getvalue())
|
|
155
|
+
|
|
156
|
+
def test_failure_keeps_cause(self):
|
|
157
|
+
flow = FailingFallbackFlow("FailingFallback", {})
|
|
158
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
159
|
+
flow.run()
|
|
160
|
+
self.assertIsInstance(ctx.exception.__cause__, ZeroDivisionError)
|
|
161
|
+
|
|
162
|
+
def test_default_fallback_is_none(self):
|
|
163
|
+
flow = DefaultFallbackFlow("DefaultFallback", {})
|
|
164
|
+
flow.run()
|
|
165
|
+
self.assertEqual(flow.get_result(flow.report), "no result")
|
|
166
|
+
|
|
167
|
+
def test_failing_fallback_fails_flow(self):
|
|
168
|
+
flow = FailingFallbackFlow("FailingFallback", {})
|
|
169
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
170
|
+
flow.run()
|
|
171
|
+
self.assertIn("division by zero", str(ctx.exception))
|
|
172
|
+
|
|
173
|
+
def test_fallback_per_node(self):
|
|
174
|
+
flow = ForeachFallbackFlow("ForeachFallback", {})
|
|
175
|
+
flow.run()
|
|
176
|
+
self.assertEqual(flow.get_result(flow.collect), [10, -1, 30])
|
|
177
|
+
statuses = [n.status for n in flow.graph.get_step(ForeachFallbackFlow.work.step).nodes]
|
|
178
|
+
self.assertEqual(statuses, [STATUS_SUCCEEDED, STATUS_FAILED, STATUS_SUCCEEDED])
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@mock.patch("nextpipe.flow.Application", _FakeApplication)
|
|
182
|
+
@mock.patch("nextpipe.flow.random.uniform", lambda a, b: 0)
|
|
183
|
+
class TestAppRunFailure(unittest.TestCase):
|
|
184
|
+
def setUp(self):
|
|
185
|
+
_FakeApplication.status = nextmv.StatusV2.failed
|
|
186
|
+
_FakeApplication.error = "solver crashed"
|
|
187
|
+
|
|
188
|
+
def test_failed_run_fails_flow(self):
|
|
189
|
+
flow = AppFlow("App", {}, client=mock.Mock(console_url="https://console"))
|
|
190
|
+
with self.assertRaises(RuntimeError) as ctx:
|
|
191
|
+
flow.run()
|
|
192
|
+
message = str(ctx.exception)
|
|
193
|
+
self.assertIn("Step solve failed", message)
|
|
194
|
+
self.assertIn("run run-solver of app solver failed: solver crashed", message)
|
|
195
|
+
self.assertIn("https://console/app/solver/run/run-solver", message)
|
|
196
|
+
self.assertIsNone(flow.get_result(flow.report))
|
|
197
|
+
# The error message describes the failed run, no traceback of nextpipe internals
|
|
198
|
+
self.assertIsNone(ctx.exception.__cause__)
|
|
199
|
+
|
|
200
|
+
def test_failed_run_with_fallback(self):
|
|
201
|
+
flow = AppFallbackFlow("AppFallback", {}, client=mock.Mock(console_url="https://console"))
|
|
202
|
+
flow.run()
|
|
203
|
+
error = flow.get_result(flow.report)
|
|
204
|
+
self.assertIsInstance(error, AppRunError)
|
|
205
|
+
self.assertEqual(error.run_id, "run-solver")
|
|
206
|
+
self.assertEqual(error.status, "failed")
|
|
207
|
+
self.assertEqual(error.error, "solver crashed")
|
|
208
|
+
|
|
209
|
+
def test_succeeded_run(self):
|
|
210
|
+
_FakeApplication.status = nextmv.StatusV2.succeeded
|
|
211
|
+
flow = AppFlow("App", {}, client=mock.Mock(console_url="https://console"))
|
|
212
|
+
flow.run()
|
|
213
|
+
self.assertEqual(flow.get_result(flow.report), {"solution": "ok"})
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
if __name__ == "__main__":
|
|
217
|
+
unittest.main()
|
|
@@ -7,7 +7,7 @@ import unittest
|
|
|
7
7
|
import nextmv.cloud
|
|
8
8
|
|
|
9
9
|
from nextpipe import FlowSpec, app, needs, step
|
|
10
|
-
from nextpipe.uplink import FlowDTO, FlowUpdateDTO, NodeDTO, StepDTO, UplinkClient
|
|
10
|
+
from nextpipe.uplink import UPDATE_INTERVAL, FlowDTO, FlowUpdateDTO, NodeDTO, StepDTO, UplinkClient, UplinkConfig
|
|
11
11
|
|
|
12
12
|
# Add the parent directory to the sys.path to allow imports from the main package. This
|
|
13
13
|
# is meant to help VS Code testing features.
|
|
@@ -107,3 +107,19 @@ class TestLogger(unittest.TestCase):
|
|
|
107
107
|
time.sleep(0.5)
|
|
108
108
|
uplink.terminate()
|
|
109
109
|
time.sleep(0.5)
|
|
110
|
+
|
|
111
|
+
def test_terminate_does_not_wait_for_update_interval(self):
|
|
112
|
+
"""Terminating must not wait out the sleep between updates (e.g., on cancellation)."""
|
|
113
|
+
|
|
114
|
+
client = nextmv.cloud.Client(api_key="unavailable", url="https://unavailable.url")
|
|
115
|
+
uplink = UplinkClient(client=client, config=UplinkConfig(application_id="app", run_id="run"))
|
|
116
|
+
posted = []
|
|
117
|
+
uplink._post_node_update = lambda: posted.append(time.monotonic())
|
|
118
|
+
uplink.run_async()
|
|
119
|
+
uplink.submit_update(_create_example_flow())
|
|
120
|
+
# Wait for the first update, the update thread is sleeping afterwards.
|
|
121
|
+
while not posted:
|
|
122
|
+
time.sleep(0.01)
|
|
123
|
+
start = time.monotonic()
|
|
124
|
+
uplink.terminate()
|
|
125
|
+
self.assertLess(time.monotonic() - start, UPDATE_INTERVAL / 2)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "v0.7.4.dev0"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|