nextpipe 0.7.4__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.
Files changed (73) hide show
  1. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/PKG-INFO +1 -1
  2. nextpipe-0.8.0.dev0/nextpipe/__about__.py +1 -0
  3. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/__init__.py +2 -0
  4. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/decorators.py +139 -0
  5. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/flow.py +253 -28
  6. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/threads.py +1 -1
  7. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/uplink.py +4 -2
  8. nextpipe-0.8.0.dev0/tests/test_cancel.py +138 -0
  9. nextpipe-0.8.0.dev0/tests/test_on_failure.py +217 -0
  10. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_uplink.py +17 -1
  11. nextpipe-0.7.4/nextpipe/__about__.py +0 -1
  12. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.github/workflows/lint.yml +0 -0
  13. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.github/workflows/release.yml +0 -0
  14. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.github/workflows/test.yml +0 -0
  15. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.gitignore +0 -0
  16. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.nextmv/bump_requirements.sh +0 -0
  17. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.prettierrc.yml +0 -0
  18. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/.python-version +0 -0
  19. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/LICENSE.md +0 -0
  20. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/README.md +0 -0
  21. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/RELEASE.md +0 -0
  22. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/config.py +0 -0
  23. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/constants.py +0 -0
  24. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/graph.py +0 -0
  25. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/schema.py +0 -0
  26. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe/utils.py +0 -0
  27. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/nextpipe.code-workspace +0 -0
  28. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/pyproject.toml +0 -0
  29. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/.gitignore +0 -0
  30. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/README.md +0 -0
  31. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/__init__.py +0 -0
  32. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo/.gitignore +0 -0
  33. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo/README.md +0 -0
  34. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo/app.yaml +0 -0
  35. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo/main.py +0 -0
  36. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo/requirements.txt +0 -0
  37. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/.gitignore +0 -0
  38. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/README.md +0 -0
  39. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/app.yaml +0 -0
  40. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/inputs/data.csv +0 -0
  41. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/inputs/input.xlsx +0 -0
  42. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/main.py +0 -0
  43. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/apps/echo-multi/requirements.txt +0 -0
  44. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/deploy/app.yaml +0 -0
  45. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/deploy/main.py +0 -0
  46. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/deploy/requirements.txt +0 -0
  47. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/appapp.json +0 -0
  48. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/appapp.json.golden +0 -0
  49. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/appapp.py +0 -0
  50. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/chain.json +0 -0
  51. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/chain.json.golden +0 -0
  52. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/chain.py +0 -0
  53. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/complex.json +0 -0
  54. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/complex.json.golden +0 -0
  55. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/complex.py +0 -0
  56. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/fail.py +0 -0
  57. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach-2-pred.json +0 -0
  58. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach-2-pred.json.golden +0 -0
  59. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach-2-pred.py +0 -0
  60. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach.json +0 -0
  61. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach.json.golden +0 -0
  62. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/foreach.py +0 -0
  63. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/inputs/data.csv +0 -0
  64. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/inputs/input.xlsx +0 -0
  65. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/pipelines/multifile.py +0 -0
  66. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_flow.py +0 -0
  67. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_graph.py +0 -0
  68. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_integration.py +0 -0
  69. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_optional.py +0 -0
  70. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_schema.py +0 -0
  71. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_threads.py +0 -0
  72. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/tests/test_version.py +0 -0
  73. {nextpipe-0.7.4 → nextpipe-0.8.0.dev0}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nextpipe
3
- Version: 0.7.4
3
+ Version: 0.8.0.dev0
4
4
  Summary: Framework for Decision Pipeline modeling and execution
5
5
  Project-URL: Homepage, https://www.nextmv.io
6
6
  Project-URL: Documentation, https://docs.nextmv.io/nextpipe
@@ -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 job.error and not self.fail:
953
+ if error and not self.fail:
888
954
  self.fail = True
889
- self.fail_reason = f"Step {reference.parent.definition.get_id()} failed: {job.error}"
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
- if polling_options.stop is None:
968
- polling_options.stop = lambda: node.cancel
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
- return app.run_result_with_polling(run_id=run_id, polling_options=polling_options, output_dir_path=temp_dir)
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
- def run(self):
1324
+ @staticmethod
1325
+ def __cancel_app_run(node: FlowNode, app: Application) -> None:
1236
1326
  """
1237
- Run the flow.
1327
+ Request the cancellation of the remote run of an app step node.
1238
1328
 
1239
- This method starts the uplink communication, executes the flow steps
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
- Raises
1243
- ------
1244
- RuntimeError
1245
- If the flow execution fails.
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
- self.uplink.submit_update(self.graph._to_uplink_dto())
1251
- self.uplink.run_async()
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
- self.uplink.terminate()
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
- # Start running the flow
1257
- open_steps: set[FlowStep] = set(self.graph.start_steps)
1258
- running_steps: set[FlowStep] = set()
1259
- closed_steps: set[FlowStep] = set()
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
- for step in running_steps:
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
- raise RuntimeError(f"Flow failed: {self.fail_reason}")
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
- time.sleep(UPDATE_INTERVAL)
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"
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