inorbit-edge-executor 3.2.0__tar.gz → 3.2.4__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 (28) hide show
  1. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/PKG-INFO +1 -1
  2. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/behavior_tree.py +42 -5
  3. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/worker.py +5 -0
  4. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/worker_pool.py +44 -3
  5. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor.egg-info/PKG-INFO +1 -1
  6. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor.egg-info/SOURCES.txt +1 -0
  7. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/pyproject.toml +2 -2
  8. inorbit_edge_executor-3.2.4/tests/test_run_action_node.py +288 -0
  9. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/LICENSE +0 -0
  10. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/README.md +0 -0
  11. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/__init__.py +0 -0
  12. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/datatypes.py +0 -0
  13. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/db.py +0 -0
  14. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/dummy_backend.py +0 -0
  15. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/exceptions.py +0 -0
  16. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/inorbit.py +0 -0
  17. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/logger.py +0 -0
  18. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/mission.py +0 -0
  19. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/observable.py +0 -0
  20. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor/sqlite_backend.py +0 -0
  21. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor.egg-info/dependency_links.txt +0 -0
  22. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor.egg-info/requires.txt +0 -0
  23. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/inorbit_edge_executor.egg-info/top_level.txt +0 -0
  24. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/setup.cfg +0 -0
  25. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/tests/test_behavior_tree.py +0 -0
  26. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/tests/test_behavior_tree_error_handler.py +0 -0
  27. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/tests/test_set_data_node.py +0 -0
  28. {inorbit_edge_executor-3.2.0 → inorbit_edge_executor-3.2.4}/tests/test_wait_expression_node.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inorbit_edge_executor
3
- Version: 3.2.0
3
+ Version: 3.2.4
4
4
  Summary: InOrbit Edge Missions Executor
5
5
  Author-email: Mariano Cereda <mariano.cereda@inorbit.ai>, Hernan Badenes <herchu@inorbit.ai>, Miguel Garcia <mike@inorbit.ai>
6
6
  License: MIT
@@ -557,6 +557,8 @@ class RunActionNode(BehaviorTree):
557
557
  action_id,
558
558
  arguments,
559
559
  target: Target = None,
560
+ max_retries: int = 3,
561
+ retry_wait_seconds: float = 5.0,
560
562
  **kwargs,
561
563
  ):
562
564
  super().__init__(**kwargs)
@@ -564,6 +566,8 @@ class RunActionNode(BehaviorTree):
564
566
  self.action_id = action_id
565
567
  self.arguments = arguments
566
568
  self.target = target
569
+ self.max_retries = max_retries
570
+ self.retry_wait_seconds = retry_wait_seconds
567
571
  if self.target is None:
568
572
  self.robot = context.robot_api
569
573
  else:
@@ -571,9 +575,29 @@ class RunActionNode(BehaviorTree):
571
575
 
572
576
  async def _execute(self):
573
577
  arguments = await self.mt.resolve_arguments(self.arguments)
574
- resp = await self.robot.execute_action(self.action_id, arguments=arguments)
575
- # TODO track action execution, as done in the app. This JSON response only guarantees
576
- # the action was *started*.
578
+
579
+ last_exception = None
580
+ for attempt in range(self.max_retries + 1):
581
+ try:
582
+ resp = await self.robot.execute_action(self.action_id, arguments=arguments)
583
+ # TODO track action execution, as done in the app. This JSON response only guarantees
584
+ # the action was *started*.
585
+ return # Success, exit retry loop
586
+ except Exception as e:
587
+ last_exception = e
588
+ if attempt < self.max_retries:
589
+ logger.warning(
590
+ f"Action execution failed (attempt {attempt + 1}/{self.max_retries + 1}): {e}. "
591
+ f"Retrying in {self.retry_wait_seconds} seconds..."
592
+ )
593
+ await asyncio.sleep(self.retry_wait_seconds)
594
+ else:
595
+ logger.error(
596
+ f"Action execution failed after {self.max_retries + 1} attempts: {e}"
597
+ )
598
+
599
+ # All retries exhausted, raise the last exception
600
+ raise last_exception
577
601
 
578
602
  async def on_pause(self):
579
603
  # TODO (Elvio): Here goes the logic to stop an action when a Mission is paused
@@ -584,15 +608,28 @@ class RunActionNode(BehaviorTree):
584
608
  object = super().dump_object()
585
609
  object["action_id"] = self.action_id
586
610
  object["arguments"] = self.arguments
611
+ object["max_retries"] = self.max_retries
612
+ object["retry_wait_seconds"] = self.retry_wait_seconds
587
613
  if self.target is not None:
588
614
  object["target"] = self.target.dump_object()
589
615
  return object
590
616
 
591
617
  @classmethod
592
- def from_object(cls, context, action_id, arguments, target=None, **kwargs):
618
+ def from_object(
619
+ cls,
620
+ context,
621
+ action_id,
622
+ arguments,
623
+ target=None,
624
+ max_retries=3,
625
+ retry_wait_seconds=5.0,
626
+ **kwargs,
627
+ ):
593
628
  if target is not None:
594
629
  target = Target.from_object(**target)
595
- return RunActionNode(context, action_id, arguments, target, **kwargs)
630
+ return RunActionNode(
631
+ context, action_id, arguments, target, max_retries, retry_wait_seconds, **kwargs
632
+ )
596
633
 
597
634
 
598
635
  class WaitExpressionNode(BehaviorTree):
@@ -38,6 +38,7 @@ class Worker(Observable):
38
38
  self._robot_api_factory = None
39
39
  self._behavior_tree = None
40
40
  self._shared_memory = shared_memory
41
+ self._task: asyncio.Task = None
41
42
 
42
43
  def id(self):
43
44
  return self._mission.id
@@ -133,6 +134,10 @@ class Worker(Observable):
133
134
  self.set_paused(True)
134
135
  self._task.cancel(CANCEL_TASK_PAUSE_MESSAGE)
135
136
 
137
+ def get_task(self) -> asyncio.Task | None:
138
+ """Get the task associated with the worker. This is used to wait for the task to complete during shutdown."""
139
+ return self._task
140
+
136
141
  async def resume(self):
137
142
  """
138
143
  Resumes the worker if it was paused. Returns True if the worker was paused and resumed, False otherwise.
@@ -103,12 +103,53 @@ class WorkerPool:
103
103
  for worker_state in serialized_workers:
104
104
  await self.execute_serialized_worker(worker_state)
105
105
 
106
- async def shutdown(self):
106
+ async def shutdown(self, timeout: float = 10.0):
107
107
  """
108
- Stops the worker pool. It's actually not doing anything more than preventing new jobs
109
- to be submitted.
108
+ Stops the worker pool gracefully by cancelling all running tasks.
109
+
110
+ Args:
111
+ timeout: Maximum time to wait for graceful shutdown before forcing cancellation
110
112
  """
113
+ if not self._running:
114
+ return
115
+
111
116
  self._running = False
117
+ logger.info("Starting worker pool shutdown...")
118
+
119
+ # Get all running workers and cancel them
120
+ async with self._mutex:
121
+ workers_to_cancel = list(self._workers.values())
122
+
123
+ if workers_to_cancel:
124
+ logger.info(f"Cancelling {len(workers_to_cancel)} running workers...")
125
+
126
+ # Cancel all workers and collect their tasks
127
+ cancelled_tasks = []
128
+ for worker in workers_to_cancel:
129
+ try:
130
+ if worker.cancel():
131
+ cancelled_tasks.append(worker.get_task())
132
+ except Exception as e:
133
+ logger.error(f"Error cancelling worker {worker.id()}: {e}")
134
+
135
+ if cancelled_tasks:
136
+ # Wait for tasks to complete cancellation with timeout
137
+ try:
138
+ await asyncio.wait_for(
139
+ asyncio.gather(*cancelled_tasks, return_exceptions=True), timeout=timeout
140
+ )
141
+ logger.info("All workers cancelled gracefully")
142
+ except asyncio.TimeoutError:
143
+ logger.warning(f"Timeout waiting for graceful shutdown after {timeout}s")
144
+ else:
145
+ logger.info("No running workers to cancel")
146
+
147
+ # Clean up workers dictionary
148
+ async with self._mutex:
149
+ self._workers.clear()
150
+
151
+ # Shutdown the database connection
152
+ await self._db.shutdown()
112
153
 
113
154
  async def notify(self, worker: Worker):
114
155
  """Notified when a worker changed its state. Persist it"""
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: inorbit_edge_executor
3
- Version: 3.2.0
3
+ Version: 3.2.4
4
4
  Summary: InOrbit Edge Missions Executor
5
5
  Author-email: Mariano Cereda <mariano.cereda@inorbit.ai>, Hernan Badenes <herchu@inorbit.ai>, Miguel Garcia <mike@inorbit.ai>
6
6
  License: MIT
@@ -21,5 +21,6 @@ inorbit_edge_executor.egg-info/requires.txt
21
21
  inorbit_edge_executor.egg-info/top_level.txt
22
22
  tests/test_behavior_tree.py
23
23
  tests/test_behavior_tree_error_handler.py
24
+ tests/test_run_action_node.py
24
25
  tests/test_set_data_node.py
25
26
  tests/test_wait_expression_node.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "inorbit_edge_executor"
7
- version = "3.2.0"
7
+ version = "3.2.4"
8
8
  description = "InOrbit Edge Missions Executor"
9
9
  readme = "README.md"
10
10
  authors = [
@@ -47,7 +47,7 @@ target-version = ['py310']
47
47
  line-length = 100
48
48
 
49
49
  [tool.bumpversion]
50
- current_version = "3.2.0"
50
+ current_version = "3.2.4"
51
51
  commit = true
52
52
  tag = true
53
53
  tag_name = "v{new_version}"
@@ -0,0 +1,288 @@
1
+ import pytest
2
+ from unittest.mock import AsyncMock, Mock
3
+
4
+ from inorbit_edge_executor.behavior_tree import RunActionNode, BehaviorTreeBuilderContext
5
+ from inorbit_edge_executor.datatypes import Target
6
+ from inorbit_edge_executor.inorbit import RobotApi, RobotApiFactory
7
+
8
+
9
+ class TestRunActionNodeRetry:
10
+ @pytest.fixture
11
+ def mock_robot_api(self):
12
+ """Mock RobotApi for testing"""
13
+ mock_api = Mock(spec=RobotApi)
14
+ mock_api.robot_id = "test_robot"
15
+ mock_api.execute_action = AsyncMock()
16
+ return mock_api
17
+
18
+ @pytest.fixture
19
+ def mock_mission_tracking(self):
20
+ """Mock mission tracking for testing"""
21
+ mock_mt = Mock()
22
+ mock_mt.resolve_arguments = AsyncMock(return_value={"param": "value"})
23
+ return mock_mt
24
+
25
+ @pytest.fixture
26
+ def context(self, mock_robot_api, mock_mission_tracking):
27
+ """Create a test context with mocked dependencies"""
28
+ context = BehaviorTreeBuilderContext()
29
+ context.robot_api = mock_robot_api
30
+ context.mt = mock_mission_tracking
31
+ return context
32
+
33
+ @pytest.mark.asyncio
34
+ async def test_execute_action_success_first_try(self, context, mock_robot_api):
35
+ """Test successful action execution on first attempt"""
36
+ # Setup
37
+ mock_robot_api.execute_action.return_value = {"status": "success"}
38
+
39
+ node = RunActionNode(
40
+ context=context,
41
+ action_id="test_action",
42
+ arguments={"param": "value"},
43
+ max_retries=3,
44
+ retry_wait_seconds=0.01,
45
+ )
46
+
47
+ # Execute
48
+ await node._execute()
49
+
50
+ # Verify
51
+ mock_robot_api.execute_action.assert_called_once_with(
52
+ "test_action", arguments={"param": "value"}
53
+ )
54
+
55
+ @pytest.mark.asyncio
56
+ async def test_execute_action_success_after_retries(self, context, mock_robot_api):
57
+ """Test successful action execution after some failures"""
58
+ # Setup - fail first 2 attempts, succeed on 3rd
59
+ mock_robot_api.execute_action.side_effect = [
60
+ Exception("Network timeout"),
61
+ Exception("Connection refused"),
62
+ {"status": "success"}, # Success on third try
63
+ ]
64
+
65
+ node = RunActionNode(
66
+ context=context,
67
+ action_id="test_action",
68
+ arguments={"param": "value"},
69
+ max_retries=3,
70
+ retry_wait_seconds=0.01,
71
+ )
72
+
73
+ # Execute
74
+ await node._execute()
75
+
76
+ # Verify
77
+ assert mock_robot_api.execute_action.call_count == 3
78
+
79
+ @pytest.mark.asyncio
80
+ async def test_execute_action_fail_all_retries(self, context, mock_robot_api):
81
+ """Test action execution failure after exhausting all retries"""
82
+ # Setup - all attempts fail
83
+ test_exception = Exception("Persistent network error")
84
+ mock_robot_api.execute_action.side_effect = test_exception
85
+
86
+ node = RunActionNode(
87
+ context=context,
88
+ action_id="test_action",
89
+ arguments={"param": "value"},
90
+ max_retries=2,
91
+ retry_wait_seconds=0.01,
92
+ )
93
+
94
+ # Execute and verify exception is raised
95
+ with pytest.raises(Exception, match="Persistent network error"):
96
+ await node._execute()
97
+
98
+ # Verify all retry attempts were made (max_retries + 1 = 3 attempts)
99
+ assert mock_robot_api.execute_action.call_count == 3
100
+
101
+ @pytest.mark.asyncio
102
+ async def test_execute_action_different_exception_types(self, context, mock_robot_api):
103
+ """Test that different types of exceptions are handled consistently"""
104
+ exceptions_to_test = [
105
+ ValueError("Invalid parameter"),
106
+ ConnectionError("Network unreachable"),
107
+ TimeoutError("Request timeout"),
108
+ RuntimeError("Service unavailable"),
109
+ ]
110
+
111
+ for exception in exceptions_to_test:
112
+ # Reset mock
113
+ mock_robot_api.execute_action.reset_mock()
114
+ mock_robot_api.execute_action.side_effect = exception
115
+
116
+ node = RunActionNode(
117
+ context=context,
118
+ action_id="test_action",
119
+ arguments={"param": "value"},
120
+ max_retries=1,
121
+ retry_wait_seconds=0.01,
122
+ )
123
+
124
+ # Execute and verify exception is raised
125
+ with pytest.raises(type(exception)):
126
+ await node._execute()
127
+
128
+ # Verify retries were attempted
129
+ assert mock_robot_api.execute_action.call_count == 2
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_execute_action_with_custom_max_retries(self, context, mock_robot_api):
133
+ """Test action execution with different max_retries values"""
134
+ test_cases = [0, 1, 5, 10]
135
+
136
+ for max_retries in test_cases:
137
+ # Reset mock
138
+ mock_robot_api.execute_action.reset_mock()
139
+ mock_robot_api.execute_action.side_effect = Exception("Always fail")
140
+
141
+ node = RunActionNode(
142
+ context=context,
143
+ action_id="test_action",
144
+ arguments={"param": "value"},
145
+ max_retries=max_retries,
146
+ retry_wait_seconds=0.01,
147
+ )
148
+
149
+ # Execute and verify exception is raised
150
+ with pytest.raises(Exception):
151
+ await node._execute()
152
+
153
+ # Verify correct number of attempts (max_retries + 1)
154
+ expected_attempts = max_retries + 1
155
+ assert mock_robot_api.execute_action.call_count == expected_attempts
156
+
157
+ @pytest.mark.asyncio
158
+ async def test_retry_delay_timing(self, context, mock_robot_api):
159
+ """Test that retry delays are working (approximate timing)"""
160
+ # Setup
161
+ mock_robot_api.execute_action.side_effect = [
162
+ Exception("Fail 1"),
163
+ Exception("Fail 2"),
164
+ {"status": "success"},
165
+ ]
166
+
167
+ node = RunActionNode(
168
+ context=context,
169
+ action_id="test_action",
170
+ arguments={"param": "value"},
171
+ max_retries=2,
172
+ retry_wait_seconds=0.01,
173
+ )
174
+
175
+ # Execute with timing
176
+ import time
177
+
178
+ start_time = time.time()
179
+ await node._execute()
180
+ end_time = time.time()
181
+
182
+ # Should take at least 0.02 seconds (2 retries * 0.01 seconds each)
183
+ # but allow some tolerance for test execution overhead
184
+ assert end_time - start_time >= 0.015
185
+
186
+ @pytest.mark.asyncio
187
+ async def test_execute_action_with_target_robot(self, mock_mission_tracking):
188
+ """Test action execution with a different target robot"""
189
+ # Setup target robot
190
+ target = Target(robotId="target_robot")
191
+
192
+ # Create mock robot API factory
193
+ mock_target_robot_api = Mock(spec=RobotApi)
194
+ mock_target_robot_api.execute_action = AsyncMock(return_value={"status": "success"})
195
+
196
+ mock_factory = Mock(spec=RobotApiFactory)
197
+ mock_factory.build.return_value = mock_target_robot_api
198
+
199
+ context = BehaviorTreeBuilderContext()
200
+ context.robot_api_factory = mock_factory
201
+ context.mt = mock_mission_tracking
202
+
203
+ node = RunActionNode(
204
+ context=context,
205
+ action_id="test_action",
206
+ arguments={"param": "value"},
207
+ target=target,
208
+ max_retries=2,
209
+ retry_wait_seconds=0.01,
210
+ )
211
+
212
+ # Execute
213
+ await node._execute()
214
+
215
+ # Verify
216
+ mock_factory.build.assert_called_once_with("target_robot")
217
+ mock_target_robot_api.execute_action.assert_called_once_with(
218
+ "test_action", arguments={"param": "value"}
219
+ )
220
+
221
+ def test_dump_object_includes_max_retries(self, context):
222
+ """Test that dump_object includes max_retries parameter"""
223
+ node = RunActionNode(
224
+ context=context,
225
+ action_id="test_action",
226
+ arguments={"param": "value"},
227
+ max_retries=5,
228
+ retry_wait_seconds=0.01,
229
+ )
230
+
231
+ dumped = node.dump_object()
232
+
233
+ assert dumped["max_retries"] == 5
234
+ assert dumped["retry_wait_seconds"] == 0.01
235
+ assert dumped["action_id"] == "test_action"
236
+ assert dumped["arguments"] == {"param": "value"}
237
+
238
+ def test_from_object_creates_node_with_max_retries(self, context):
239
+ """Test that from_object correctly handles max_retries parameter"""
240
+ node = RunActionNode.from_object(
241
+ context=context,
242
+ action_id="test_action",
243
+ arguments={"param": "value"},
244
+ max_retries=7,
245
+ retry_wait_seconds=0.01,
246
+ )
247
+
248
+ assert node.max_retries == 7
249
+ assert node.retry_wait_seconds == 0.01
250
+ assert node.action_id == "test_action"
251
+ assert node.arguments == {"param": "value"}
252
+
253
+ def test_from_object_defaults_max_retries(self, context):
254
+ """Test that from_object uses default max_retries when not specified"""
255
+ node = RunActionNode.from_object(
256
+ context=context, action_id="test_action", arguments={"param": "value"}
257
+ )
258
+
259
+ assert node.max_retries == 3 # Default value
260
+ assert node.retry_wait_seconds == 5.0 # Default value
261
+
262
+ def test_serialization_round_trip(self, context):
263
+ """Test that serialization and deserialization preserve max_retries"""
264
+ original_node = RunActionNode(
265
+ context=context,
266
+ action_id="test_action",
267
+ arguments={"param": "value"},
268
+ max_retries=4,
269
+ retry_wait_seconds=0.01,
270
+ label="test_label",
271
+ )
272
+
273
+ # Serialize
274
+ dumped = original_node.dump_object()
275
+
276
+ # Remove type field that's used by the serialization system
277
+ dumped_for_restore = dumped.copy()
278
+ del dumped_for_restore["type"]
279
+
280
+ # Deserialize
281
+ restored_node = RunActionNode.from_object(context=context, **dumped_for_restore)
282
+
283
+ # Verify all properties are preserved
284
+ assert restored_node.max_retries == 4
285
+ assert restored_node.retry_wait_seconds == 0.01
286
+ assert restored_node.action_id == "test_action"
287
+ assert restored_node.arguments == {"param": "value"}
288
+ assert restored_node.label == "test_label"