Skip to content

Commit b6f1db8

Browse files
Add UnitesStrategyEnum and integrate into NodeTemplate validation (#328)
* Add UnitesStrategyEnum and integrate into NodeTemplate validation - Introduced UnitesStrategyEnum to define strategies for unites: ALL_SUCCESS and ALL_DONE. - Updated NodeTemplate to include a strategy field, enhancing the model's validation capabilities. - Modified the check_unites_satisfied function in create_next_states.py to incorporate the new strategy logic for state satisfaction checks, improving the handling of unites conditions. * Update .codespellignorewords to include 'Kusto' and 'NotIn' - Added 'Kusto' and 'NotIn' to the list of ignored words for codespell, improving spell-checking accuracy in the project. * uv run ruff check --fix * Update strategy field in Unites model to have a default value - Changed the `strategy` field in the `Unites` model to use a default value of `UnitesStrategyEnum.ALL_SUCCESS`, enhancing the model's usability by providing a predefined strategy option. * Add tests for ALL_DONE strategy in check_unites_satisfied function - Introduced two new test cases to validate the behavior of the check_unites_satisfied function when using the ALL_DONE strategy. - The first test checks for pending states, asserting that the result is False. - The second test verifies the absence of pending states, asserting that the result is True. - Updated the NodeTemplate model to include UnitesStrategyEnum for enhanced strategy handling. * Add Unites Strategy documentation for ALL_SUCCESS and ALL_DONE * Enhance documentation and logic for unites strategies - Updated the `ALL_DONE` strategy description to include additional terminal statuses: `CANCELLED`, `NEXT_CREATED_ERROR`, and `PRUNED`, clarifying its behavior. - Added a caution note to the `ALL_SUCCESS` strategy, highlighting potential indefinite blocking scenarios and suggesting the use of timeouts or fallback strategies. - Refactored the `check_unites_satisfied` function to improve clarity and maintainability in state satisfaction checks for both `ALL_SUCCESS` and `ALL_DONE` strategies. * Refactor tests in check_unites_satisfied to use find_one method - Updated test cases in `test_create_next_states.py` to replace the use of `find` with `find_one` for better clarity and accuracy in state checks. - Adjusted mock return values to reflect pending and non-pending states, enhancing the reliability of the tests. * Update docs/docs/exosphere/architecture.md Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent d5eaf1b commit b6f1db8

5 files changed

Lines changed: 132 additions & 12 deletions

File tree

.github/.codespellignorewords

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,5 @@ SLA
1313
YML
1414
SDK
1515
S3
16-
Kusto
16+
Kusto
17+
NotIn

docs/docs/exosphere/architecture.md

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,58 @@ When a node has a `unites` configuration:
8080
2. **State fingerprinting** ensures only one unites state is created per unique combination
8181
3. **Dependency validation** ensures the unites node depends on the specified identifier
8282

83+
### Unites Strategy (Beta)
84+
85+
The `unites` keyword supports different strategies to control when the uniting node should execute. This feature is currently in **beta**.
86+
87+
#### Available Strategies
88+
89+
- **`ALL_SUCCESS`** (default): The uniting node executes only when all states with the specified identifier have reached `SUCCESS` status. If any state fails or is still processing, the uniting node will wait.
90+
91+
- **`ALL_DONE`**: The uniting node executes when all states with the specified identifier have reached any terminal status (`SUCCESS`, `ERRORED`, `CANCELLED`, `NEXT_CREATED_ERROR`, or `PRUNED`). This strategy allows the uniting node to proceed even if some states have failed.
92+
93+
#### Strategy Configuration
94+
95+
You can specify the strategy in your unites configuration:
96+
97+
```json hl_lines="22-25"
98+
{
99+
"nodes": [
100+
{
101+
"node_name": "DataSplitterNode",
102+
"identifier": "data_splitter",
103+
"next_nodes": ["processor_1"]
104+
},
105+
{
106+
"node_name": "DataProcessorNode",
107+
"identifier": "processor_1",
108+
"inputs":{
109+
"x":"${{data_splitter.outputs.data_chunk}}"
110+
},
111+
"next_nodes": ["result_merger"]
112+
},
113+
{
114+
"node_name": "ResultMergerNode",
115+
"identifier": "result_merger",
116+
"inputs":{
117+
"x_processed":"${{processor_1.outputs.processed_data}}"
118+
},
119+
"unites": {
120+
"identifier": "data_splitter",
121+
"strategy": "ALL_SUCCESS"
122+
},
123+
"next_nodes": []
124+
}
125+
]
126+
}
127+
```
128+
129+
#### Use Cases
130+
131+
- **`ALL_SUCCESS`**: Use when you need all parallel processes to complete successfully before proceeding. Ideal for data processing workflows where partial failures are not acceptable. **Caution**: This strategy can block indefinitely if any parallel branch never reaches a SUCCESS terminal state. Consider adding timeouts, explicit failure-to-success fallbacks, or using ALL_DONE when partial results are acceptable. Implement watchdogs or retry/timeout policies in workflows to prevent permanent blocking.
132+
133+
- **`ALL_DONE`**: Use when you want to proceed with partial results or when you have error handling logic in the uniting node. Useful for scenarios where you want to aggregate results from successful processes while handling failures separately.
134+
83135
### Unites Example
84136

85137
```json hl_lines="22-24"
@@ -95,15 +147,15 @@ When a node has a `unites` configuration:
95147
"identifier": "processor_1",
96148
"inputs":{
97149
"x":"${{data_splitter.outputs.data_chunk}}"
98-
}
150+
},
99151
"next_nodes": ["result_merger"]
100152
},
101153
{
102154
"node_name": "ResultMergerNode",
103155
"identifier": "result_merger",
104156
"inputs":{
105157
"x_processed":"${{processor_1.outputs.processed_data}}"
106-
}
158+
},
107159
"unites": {
108160
"identifier": "data_splitter"
109161
},

state-manager/app/models/node_template_model.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,17 @@
11
from pydantic import Field, BaseModel, field_validator
22
from typing import Any, Optional, List
33
from .dependent_string import DependentString
4+
from enum import Enum
5+
6+
7+
class UnitesStrategyEnum(str, Enum):
8+
ALL_SUCCESS = "ALL_SUCCESS"
9+
ALL_DONE = "ALL_DONE"
410

511

612
class Unites(BaseModel):
713
identifier: str = Field(..., description="Identifier of the node")
14+
strategy: UnitesStrategyEnum = Field(default=UnitesStrategyEnum.ALL_SUCCESS, description="Strategy of the unites")
815

916

1017
class NodeTemplate(BaseModel):

state-manager/app/tasks/create_next_states.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from app.models.node_template_model import NodeTemplate
99
from app.models.db.registered_node import RegisteredNode
1010
from app.models.dependent_string import DependentString
11+
from app.models.node_template_model import UnitesStrategyEnum
1112
from json_schema_to_pydantic import create_model
1213
from pydantic import BaseModel
1314
from typing import Type
@@ -30,15 +31,30 @@ async def check_unites_satisfied(namespace: str, graph_name: str, node_template:
3031
if not unites_id:
3132
raise ValueError(f"Unit identifier not found in parents: {node_template.unites.identifier}")
3233
else:
33-
if await State.find(
34+
if node_template.unites.strategy == UnitesStrategyEnum.ALL_SUCCESS:
35+
any_one_pending = await State.find_one(
3436
State.namespace_name == namespace,
3537
State.graph_name == graph_name,
3638
NE(State.status, StateStatusEnum.SUCCESS),
3739
{
3840
f"parents.{node_template.unites.identifier}": unites_id
3941
}
40-
).count() > 0:
42+
)
43+
if any_one_pending:
44+
return False
45+
46+
if node_template.unites.strategy == UnitesStrategyEnum.ALL_DONE:
47+
any_one_pending = await State.find_one(
48+
State.namespace_name == namespace,
49+
State.graph_name == graph_name,
50+
In(State.status, [StateStatusEnum.CREATED, StateStatusEnum.QUEUED, StateStatusEnum.EXECUTED]),
51+
{
52+
f"parents.{node_template.unites.identifier}": unites_id
53+
}
54+
)
55+
if any_one_pending:
4156
return False
57+
4258
return True
4359

4460

state-manager/tests/unit/tasks/test_create_next_states.py

Lines changed: 51 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
)
1010
from app.models.dependent_string import Dependent, DependentString
1111
from app.models.state_status_enum import StateStatusEnum
12-
from app.models.node_template_model import NodeTemplate, Unites
12+
from app.models.node_template_model import NodeTemplate, Unites, UnitesStrategyEnum
1313
from pydantic import BaseModel
1414

1515

@@ -233,9 +233,9 @@ async def test_check_unites_satisfied_pending_states(self):
233233
parents = {"parent1": PydanticObjectId()}
234234

235235
with patch('app.tasks.create_next_states.State') as mock_state:
236-
mock_find = AsyncMock()
237-
mock_find.count.return_value = 1
238-
mock_state.find.return_value = mock_find
236+
mock_find_one = AsyncMock()
237+
mock_find_one.return_value = {"some": "state"} # Return a non-None value to indicate pending state
238+
mock_state.find_one = mock_find_one
239239

240240
result = await check_unites_satisfied("test_namespace", "test_graph", node_template, parents)
241241

@@ -255,9 +255,53 @@ async def test_check_unites_satisfied_no_pending_states(self):
255255
parents = {"parent1": PydanticObjectId()}
256256

257257
with patch('app.tasks.create_next_states.State') as mock_state:
258-
mock_find = AsyncMock()
259-
mock_find.count.return_value = 0
260-
mock_state.find.return_value = mock_find
258+
mock_find_one = AsyncMock()
259+
mock_find_one.return_value = None # Return None to indicate no pending state
260+
mock_state.find_one = mock_find_one
261+
262+
result = await check_unites_satisfied("test_namespace", "test_graph", node_template, parents)
263+
264+
assert result is True
265+
266+
@pytest.mark.asyncio
267+
async def test_check_unites_satisfied_all_done_strategy_pending_states(self):
268+
"""Test when there are pending states for ALL_DONE strategy"""
269+
node_template = NodeTemplate(
270+
node_name="test_node",
271+
identifier="test_id",
272+
namespace="test",
273+
inputs={},
274+
next_nodes=None,
275+
unites=Unites(identifier="parent1", strategy=UnitesStrategyEnum.ALL_DONE)
276+
)
277+
parents = {"parent1": PydanticObjectId()}
278+
279+
with patch('app.tasks.create_next_states.State') as mock_state:
280+
mock_find_one = AsyncMock()
281+
mock_find_one.return_value = {"some": "state"} # Return a non-None value to indicate pending state
282+
mock_state.find_one = mock_find_one
283+
284+
result = await check_unites_satisfied("test_namespace", "test_graph", node_template, parents)
285+
286+
assert result is False
287+
288+
@pytest.mark.asyncio
289+
async def test_check_unites_satisfied_all_done_strategy_no_pending_states(self):
290+
"""Test when there are no pending states for ALL_DONE strategy"""
291+
node_template = NodeTemplate(
292+
node_name="test_node",
293+
identifier="test_id",
294+
namespace="test",
295+
inputs={},
296+
next_nodes=None,
297+
unites=Unites(identifier="parent1", strategy=UnitesStrategyEnum.ALL_DONE)
298+
)
299+
parents = {"parent1": PydanticObjectId()}
300+
301+
with patch('app.tasks.create_next_states.State') as mock_state:
302+
mock_find_one = AsyncMock()
303+
mock_find_one.return_value = None # Return None to indicate no pending state
304+
mock_state.find_one = mock_find_one
261305

262306
result = await check_unites_satisfied("test_namespace", "test_graph", node_template, parents)
263307

0 commit comments

Comments
 (0)