Skip to content

Commit 2be2162

Browse files
GuanyiLi-CraigGuanyi LiCopilot
authored
refactor the async flow, using event driven approach (#68)
* refactor the async flow, using event driven approach * Update grafi/workflows/impl/utils.py address issues Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update grafi/workflows/impl/utils.py improve efficiency Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * simplified the code * remove sleep * update code and document * update code, improve tests * fix lint * fix unit test error * address comment --------- Co-authored-by: Guanyi Li <guanyili@Guanyis-MacBook-Air.local> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 2bb528b commit 2be2162

45 files changed

Lines changed: 4691 additions & 2981 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ jobs:
9393
set -e
9494
output=$(uv run python tests_integration/run_all.py)
9595
echo "$output"
96-
96+
9797
# Check if there are any failed tests
9898
if echo "$output" | grep -q "Failed scripts:"; then
9999
echo "❌ Integration tests failed! Check the output above for details."

docs/docs/guide/debugging-with-grafi-dev.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,19 +78,19 @@ Create a `grafi-dev.config.js` file in your project root:
7878
module.exports = {
7979
// Port for the development server
8080
port: 3001,
81-
81+
8282
// Path to your Graphite application
8383
appPath: './workflow.py',
84-
84+
8585
// Enable hot reload
8686
hotReload: true,
87-
87+
8888
// Event filtering (optional)
8989
eventFilters: [
9090
'user.*',
9191
'response.*'
9292
],
93-
93+
9494
// Debug settings
9595
debug: {
9696
pauseOnError: true,
@@ -480,14 +480,14 @@ Extend grafi-dev with custom plugins:
480480
// custom-plugin.js
481481
module.exports = {
482482
name: 'custom-metrics',
483-
483+
484484
onEventProcessed(event, duration) {
485485
// Send metrics to external system
486486
metricsClient.timing('event.processed', duration, {
487487
event_type: event.type
488488
});
489489
},
490-
490+
491491
onError(error, event) {
492492
// Custom error handling
493493
errorTracker.captureException(error, {
@@ -515,14 +515,14 @@ module.exports = {
515515
debug: { logLevel: 'debug' },
516516
hotReload: true
517517
},
518-
518+
519519
staging: {
520520
port: 3002,
521521
debug: { logLevel: 'info' },
522522
hotReload: false,
523523
performance: { samplingRate: 0.5 }
524524
},
525-
525+
526526
production: {
527527
port: 3003,
528528
debug: { logLevel: 'error' },

docs/docs/user-guide/conventional-rules.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,19 @@ While the platform is designed for maximum flexibility, certain conventions guid
1414
- **Final Responses**: All output events route to **agent_output_topic**, which the Assistant consumes to return data to the user or caller.
1515
- **Single Consumer**: Only the Assistant should subscribe to this topic, avoiding conflicting read operations.
1616

17-
### Human Request Topic
17+
### InWorkflow Topics
1818

19-
- **Human in the Loop**: Used when user intervention is required; the system posts an `OutputTopicEvent` here, which the Assistant can consume to display prompts or questions.
20-
- **User Response**: When the user replies, `append_user_input()` posts a `PublishToTopicEvent` (the user’s answer). This message is then read by downstream nodes.
21-
- **Assistant Role**: The Assistant only consumes `OutputTopicEvent` objects, while nodes consume both the question (`OutputTopicEvent`) and the final user reply (`PublishToTopicEvent`).
19+
- **Human in the Loop**: Used when user intervention is required within workflows; the system uses paired InWorkflowOutputTopic and InWorkflowInputTopic for coordinated human interactions.
20+
- **InWorkflowOutputTopic**: Posts `OutputTopicEvent` objects that require human interaction or review.
21+
- **InWorkflowInputTopic**: Receives user responses via `publish_input_data()` based on upstream events from the paired output topic.
22+
- **Workflow Coordination**: These topics work together to enable seamless human-in-the-loop workflows with proper event coordination.
2223

2324
**Rationale**: Structuring input and output channels ensures clarity, preventing multiple consumers from inadvertently processing final outputs and providing a clear path for user-driven requests.
2425

2526
## OutputTopicEvent
2627

27-
- **Dedicated for Assistant**: If a newly received event is an `OutputTopicEvent`, the workflows `on_event()` skips subscription checks, since only the Assistant should consume it.
28-
- **Exclusive Destination**: `OutputTopicEvent` can only be published to **agent_output_topic** or **human_request_topic**, ensuring a clear boundary for user-facing outputs.
28+
- **Dedicated for Assistant**: If a newly received event is an `OutputTopicEvent`, the workflow's `on_event()` skips subscription checks, since only the Assistant should consume it.
29+
- **Exclusive Destination**: `OutputTopicEvent` can only be published to **agent_output_topic** or **InWorkflowOutputTopic**, ensuring a clear boundary for user-facing outputs.
2930

3031
**Rationale**: Limiting `OutputTopicEvent` usage avoids confusion over who should read final results, reinforcing the principle of single responsibility for returning data to the user.
3132

docs/docs/user-guide/event-driven-workflow.md

Lines changed: 141 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ The fields of the EventDrivenWorkflow are:
1616
| `_topics` | `Dict[str, TopicBase]` | Private dictionary storing all event topics managed by the workflow. |
1717
| `_topic_nodes` | `Dict[str, List[str]]` | Private mapping of topic names to lists of node names subscribed to each topic. |
1818
| `_invoke_queue` | `deque[Node]` | Private queue of nodes that are ready to execute, triggered by event availability. |
19+
| `_tracker` | `AsyncNodeTracker` | Tracks active nodes and workflow idle state for proper async termination. |
1920

2021
## Methods
2122

@@ -28,15 +29,12 @@ The following table summarizes key methods within the EventDrivenWorkflow class,
2829
| `_add_topics` | Sets up topic subscriptions and node-to-topic mappings from node configurations. |
2930
| `_add_topic` | Registers a topic within the workflow and sets default publish event handlers. |
3031
| `_handle_function_calling_nodes` | Links function calling nodes with LLM nodes to enable function specification sharing. |
31-
| `_publish_events` | Publishes events to designated workflow topics after a node completes synchronous execution. |
32-
| `_publish_agen_events` | Handles publishing events for asynchronous generators, supporting streaming output. |
33-
| `_get_consumed_events` | Retrieves and returns consumed events from human request and agent output topics. |
32+
| `_a_commit_events` | Commits processed events to their respective topics, updating consumer offsets. |
33+
| `_get_output_events` | Retrieves and returns consumed events from agent output and in-workflow output topics. |
3434
| `invoke` | Synchronous workflow execution that processes nodes from the invoke queue until completion. |
3535
| `a_invoke` | Asynchronous workflow execution with streaming support and concurrent node processing. |
36-
| `_process_all_nodes` | Processes all nodes asynchronously without blocking event streaming, managing concurrent execution. |
37-
| `_record_consumed_events` | Records consumed output events to the event store with proper streaming handling. |
3836
| `_invoke_node` | Invokes a single node asynchronously with proper stream handling and error management. |
39-
| `get_node_input` | Collects and returns input events consumed by a node based on its subscribed topics. |
37+
| `a_init_workflow` | Asynchronously initializes workflow state, either restoring from stored events or creating new workflow with input data. |
4038
| `on_event` | Event handler that responds to topic publish events, evaluates node readiness, and queues nodes for execution. |
4139
| `initial_workflow` | Initializes workflow state, either restoring from stored events or creating new workflow with input data. |
4240
| `to_dict` | Serializes the workflow to a dictionary representation including nodes, topics, and topic-node mappings. |
@@ -106,10 +104,20 @@ The EventDrivenWorkflow supports both synchronous (`invoke`) and asynchronous (`
106104

107105
### Asynchronous Execution (`a_invoke`)
108106

109-
1. **Workflow Initialization**: Sets up initial state
110-
2. **Concurrent Processing**: Uses `_process_all_nodes` to handle nodes concurrently
111-
3. **Event Streaming**: Streams events as they arrive from `agent_output_topic.event_queue`
112-
4. **Task Management**: Manages running tasks and executing nodes to prevent conflicts
107+
The asynchronous execution model provides sophisticated event-driven processing with proper coordination:
108+
109+
1. **Workflow Initialization**: Sets up initial state with `a_init_workflow`
110+
2. **Concurrent Node Processing**: Spawns individual tasks for each node using `_invoke_node`
111+
3. **Output Listening**: Creates listeners for each output topic to capture results
112+
4. **Event Streaming**: Uses `MergeIdleQueue` to stream events as they become available
113+
5. **Proper Termination**: Coordinates workflow completion using `AsyncNodeTracker`
114+
115+
#### Key Components
116+
117+
- **AsyncNodeTracker**: Manages active node state and idle detection
118+
- **Output Listeners**: Monitor output topics for new events
119+
- **MergeIdleQueue**: Coordinates between event availability and workflow idle state
120+
- **Offset Management**: Commits events immediately to prevent duplicates
113121

114122
### Event-Driven Node Triggering
115123

@@ -148,6 +156,129 @@ The system provides flexible subscription expressions through topic expressions
148156

149157
- **AND Logic**: Node executes when ALL subscription conditions are met
150158
- **OR Logic**: Node executes when ANY subscription condition is met
159+
160+
## Async Flow Architecture
161+
162+
The EventDrivenWorkflow implements a sophisticated async architecture that addresses the challenges of coordinating multiple concurrent nodes while ensuring proper workflow termination and data consistency.
163+
164+
### Workflow Components
165+
166+
The async workflow relies on two key components for coordination and output management:
167+
168+
#### AsyncNodeTracker
169+
170+
The `AsyncNodeTracker` manages workflow state and coordination by tracking active nodes, monitoring processing cycles, and detecting idle states. It provides the foundation for proper workflow termination detection.
171+
172+
#### AsyncOutputQueue
173+
174+
The `AsyncOutputQueue` manages the collection and streaming of output events from multiple topics. It coordinates concurrent listeners and provides a unified async iterator interface for consuming events.
175+
176+
For detailed information about these components, see the [Workflow Components documentation](workflow-components.md).
177+
178+
### Component Integration
179+
180+
The async workflow integrates these components to provide robust event streaming:
181+
182+
```python
183+
# Create output queue with topics and tracker
184+
output_queue = AsyncOutputQueue(output_topics, self.name, self._tracker)
185+
await output_queue.start_listeners()
186+
187+
# Stream events as they arrive
188+
async for event in output_queue:
189+
yield event.data
190+
```
191+
192+
### Offset Management and Duplicate Prevention
193+
194+
The async workflow implements proper offset management to prevent duplicate data:
195+
196+
```python
197+
async for event in MergeIdleQueue(queue, self._tracker):
198+
consumed_output_event = ConsumeFromTopicEvent(...)
199+
200+
# Commit BEFORE yielding to prevent duplicate data
201+
await self._a_commit_events(
202+
consumer_name=self.name, events=[consumed_output_event]
203+
)
204+
205+
# Now yield the data after committing
206+
yield event.data
207+
```
208+
209+
#### Key Principles
210+
211+
- **Immediate Consumption**: Consumed offset advanced on fetch
212+
- **Commit Before Yield**: Prevents duplicate data in output stream
213+
- **Atomic Operations**: Event processing and commitment are coordinated
214+
215+
### Node Lifecycle in Async Mode
216+
217+
Each node runs in its own async task with proper coordination:
218+
219+
```python
220+
async def _invoke_node(self, invoke_context: InvokeContext, node: Node):
221+
buffer: Dict[str, List[TopicEvent]] = {}
222+
223+
try:
224+
while not self._stop_requested:
225+
# Wait for node to have sufficient data
226+
await wait_node_invoke(node)
227+
228+
# Signal node is becoming active
229+
await self._tracker.enter(node.name)
230+
231+
try:
232+
# Process events and publish results
233+
async for msgs in node.a_invoke(invoke_context, consumed_events):
234+
published_events = await a_publish_events(...)
235+
# Notify downstream nodes immediately
236+
for event in published_events:
237+
if event.topic_name in self._topic_nodes:
238+
topic = self._topics[event.topic_name]
239+
async with topic.event_cache._cond:
240+
topic.event_cache._cond.notify_all()
241+
242+
# Commit processed events
243+
await self._a_commit_events(...)
244+
245+
finally:
246+
# Signal node is no longer active
247+
await self._tracker.leave(node.name)
248+
249+
except asyncio.CancelledError:
250+
logger.info(f"Node {node.name} was cancelled")
251+
raise
252+
```
253+
254+
#### Node Coordination Features
255+
256+
- **Buffer Management**: Each node maintains its own event buffer
257+
- **Activity Signaling**: Nodes signal when they become active/inactive
258+
- **Immediate Notification**: Downstream nodes notified immediately after publishing
259+
- **Proper Cleanup**: Resources cleaned up on cancellation
260+
261+
### Workflow Termination Logic
262+
263+
The workflow terminates when all conditions are met:
264+
265+
1. **No Active Nodes**: `AsyncNodeTracker` reports idle state
266+
2. **No Pending Data**: Output topics have no unconsumed data
267+
3. **No Progress**: Activity count indicates no new processing cycles
268+
269+
```python
270+
# Check for workflow completion
271+
if tracker.is_idle() and not topic.can_consume(consumer_name):
272+
current_activity = tracker.get_activity_count()
273+
274+
# If no new activity since last check, we're done
275+
if current_activity == last_activity_count:
276+
break
277+
278+
last_activity_count = current_activity
279+
```
280+
281+
This multi-layered approach ensures that workflows terminate cleanly without missing data or creating race conditions between upstream completion and downstream activation.
151282
- **Complex Expressions**: Combination of AND/OR operators for advanced logic
152283

153284
**Important Consideration for OR Logic**: When using OR-based subscriptions, nodes are queued as soon as one condition is satisfied. Messages from other subscribed topics in the OR expression may not be available when `get_node_input` executes, potentially causing data inconsistencies. Careful design is recommended when implementing OR-based logic.

0 commit comments

Comments
 (0)