Skip to content
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
f080f0b
feat: add core graph execution debugging infrastructure
ogabrielluiz Oct 18, 2025
8bce7cc
test: add comprehensive test suite for execution debugger
ogabrielluiz Oct 18, 2025
c61d063
feat: add interactive debugging examples
ogabrielluiz Oct 18, 2025
121d940
feat: centralize graph mutations with async event system
ogabrielluiz Oct 20, 2025
0ab3248
refactor: remove deprecated debugging utilities
ogabrielluiz Oct 20, 2025
b2063cf
feat: add pure observer pattern event recorder
ogabrielluiz Oct 20, 2025
52c711c
docs: add comprehensive event debugging notebook
ogabrielluiz Oct 20, 2025
4246754
refactor: enhance event debugging notebook with structured outputs
ogabrielluiz Oct 20, 2025
6e2f430
chore: update ruff exclusion list to include Jupyter notebook examples
ogabrielluiz Oct 20, 2025
a2c2e65
refactor: replace direct access to _run_queue with getter method
ogabrielluiz Oct 20, 2025
44a3b4b
chore: update ruff exclusion list and add new init file
ogabrielluiz Oct 20, 2025
a0f4e56
refactor: remove marimo notebook
ogabrielluiz Oct 20, 2025
b948f1b
refactor: remove obsolete test files and debugging utilities
ogabrielluiz Oct 20, 2025
866ea49
feat: add event saving and loading functionality to EventBasedRecording
ogabrielluiz Oct 20, 2025
9d73199
update
HimavarshaVS Nov 10, 2025
25aa1f2
add md files
HimavarshaVS Nov 10, 2025
b93eeca
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 10, 2025
0a7c61c
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Nov 10, 2025
3294dfd
Merge branch main into graph-debugger
ogabrielluiz Nov 26, 2025
66aa46e
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 26, 2025
d136c33
chore: remove PR description files from repo root
ogabrielluiz Nov 26, 2025
dbf8d8b
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 26, 2025
46e138a
feat: update save and load methods to use JSON serialization for even…
ogabrielluiz Nov 26, 2025
8d117f7
fix: update event list types to use GraphMutationEvent for better typ…
ogabrielluiz Nov 26, 2025
a9a0195
feat: convert item_output and update_dependency methods to async for …
ogabrielluiz Nov 26, 2025
18739ff
feat: add tests for event-based graph recorder and mutation event system
ogabrielluiz Nov 26, 2025
6904ea2
fix: remove marimo dependency from development requirements
ogabrielluiz Nov 26, 2025
7584b3c
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 26, 2025
892c742
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Nov 26, 2025
e879125
export debug classes in dunder init
ogabrielluiz Nov 26, 2025
103cec3
feat: refactor summary and timeline display methods to return strings…
ogabrielluiz Nov 26, 2025
3106ac6
feat: make remove_from_predecessors and remove_vertex_from_runnables …
ogabrielluiz Nov 26, 2025
c128c6f
Merge branch 'main' into graph-debugger
ogabrielluiz Dec 18, 2025
2268e97
[autofix.ci] apply automated fixes
autofix-ci[bot] Dec 18, 2025
5d75fe5
[autofix.ci] apply automated fixes (attempt 2/3)
autofix-ci[bot] Dec 18, 2025
f0cf5f5
[autofix.ci] apply automated fixes (attempt 3/3)
autofix-ci[bot] Dec 18, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions PR_DESCRIPTION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Graph Execution Debugging and Event System

## Overview

This PR introduces a comprehensive event-based debugging system for Langflow graph execution, enabling detailed tracking and analysis of graph state mutations during execution. The implementation uses a pure observer pattern that provides zero overhead when not in use, making it production-safe.

## Key Features

### 🎯 Graph Mutation Event System

- **Event Infrastructure**: New `GraphMutationEvent` system that tracks all graph state changes with before/after snapshots
- **Observer Pattern**: Pure observer pattern implementation with `register_observer()` and `unregister_observer()` methods
- **Zero Overhead**: Fast path when no observers are registered, ensuring no performance impact in production
- **Serializable Events**: Events can be serialized to dictionaries for replay and storage

### 📊 Event-Based Recording

- **EventRecorder**: Observer that captures all graph mutations during execution
- **EventBasedRecording**: Rich recording object with analysis methods:
- `get_events_by_type()` - Filter events by type
- `get_events_for_vertex()` - Get all events for a specific vertex
- `get_queue_evolution()` - Track how the execution queue changes over time
- `get_dependency_changes()` - Monitor dependency modifications
- `show_summary()`, `show_timeline()`, `show_events_for_component()` - Visualization methods
- **Save/Load**: Recordings can be saved to and loaded from files for later analysis

### 🔧 Graph Execution Improvements

#### Loop Component Enhancements
- **Synchronized Dependencies**: Loop component now properly updates both `run_predecessors` and `run_map` to keep dependency structures synchronized
- **State Reset**: New `reset_loop_state()` method for clean loop state management between executions
- **Better Documentation**: Added critical comments explaining the relationship between dependency structures

#### Graph Manager Refactoring
- **Async Methods**: Made `remove_from_predecessors()` and `remove_vertex_from_runnables()` async for consistency
- **Sync Variants**: Added `mark_branch_sync()` for synchronous contexts (used by custom components)
- **Centralized Mutations**: All graph mutations now go through centralized methods that emit events

### 🧪 Testing Infrastructure

#### Execution Path Validation
- **Path Equivalence Testing**: New test suite that validates both `async_start()` and `arun()` execution paths produce identical results
- **Test Data Flows**: Uses test flows that don't require API keys for reliable CI testing
- **Comprehensive Tracing**: `ExecutionTracer` captures complete execution traces for comparison

#### Event System Tests
- **Mutation Event Tests**: Tests for queue operations, dependency updates, and event emission
- **Event Recorder Tests**: Tests for event capture, queue evolution tracking, and recording analysis
- **Graph Mutation Tests**: Tests ensuring both dependency structures stay synchronized

### 🛠️ Component Validation Improvements

- **TYPE_CHECKING Block Support**: Component validation now properly handles `TYPE_CHECKING` blocks, extracting imports needed for `get_type_hints()` to work correctly
- **Better Error Handling**: Improved error handling for components defined in notebooks or REPL environments
- **Source Code Extraction**: More robust source code extraction with graceful fallbacks

## Technical Details

### Event Types Tracked

- `queue_extended` - When vertices are added to the execution queue
- `queue_dequeued` - When vertices are removed from the queue
- `dependency_added` - When dynamic dependencies are added
- `vertex_marked` - When vertex states change (ACTIVE/INACTIVE)

### Architecture

```
Graph
├── register_observer() / unregister_observer()
├── _emit_event() - Emits events to all observers
└── All mutations → emit before/after events
├── extend_run_queue()
├── add_dynamic_dependency()
├── mark_branch_sync()
└── remove_from_predecessors()
```
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Specify a language on this fence to satisfy markdownlint.

markdownlint (MD040) is flagging this block because the fence lacks a language hint. Adding something like text keeps the ASCII diagram readable and unblocks the lint job.

-```
+```text
 Graph
   ├── register_observer() / unregister_observer()
   ├── _emit_event() - Emits events to all observers
   └── All mutations → emit before/after events
        ├── extend_run_queue()
        ├── add_dynamic_dependency()
        ├── mark_branch_sync()
        └── remove_from_predecessors()

<details>
<summary>🧰 Tools</summary>

<details>
<summary>🪛 markdownlint-cli2 (0.18.1)</summary>

68-68: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

</details>

</details>

<details>
<summary>🤖 Prompt for AI Agents</summary>

In PR_DESCRIPTION.md around lines 66 to 77, the fenced code block containing the
ASCII architecture diagram lacks a language hint which triggers markdownlint
MD040; update the opening fence to include a language (for example change totext) so the block becomes a ```text fenced block, keep the diagram content
unchanged, save the file and re-run the linter to confirm the warning is
resolved.


</details>

<!-- fingerprinting:phantom:medusa:sabertoothed -->

<!-- This is an auto-generated comment by CodeRabbit -->


### Usage Example

```python
from lfx.graph.graph.base import Graph
from lfx.debug.event_recorder import record_graph_with_events

# Record graph execution
graph = Graph.from_payload(flow_data)
recording = await record_graph_with_events(graph, "My Flow")

# Analyze the recording
recording.show_summary()
recording.show_timeline()

# Get specific insights
queue_evolution = recording.get_queue_evolution()
dependency_changes = recording.get_dependency_changes()

# Save for later analysis
recording.save("flow_recording.pkl")
```

## Files Changed

### New Files
- `src/lfx/src/lfx/debug/__init__.py` - Debug module initialization
- `src/lfx/src/lfx/debug/events.py` - GraphMutationEvent and observer types
- `src/lfx/src/lfx/debug/event_recorder.py` - EventRecorder and EventBasedRecording
- `src/backend/tests/unit/graph/test_execution_path_validation.py` - Execution path equivalence tests
- `src/backend/tests/unit/graph/test_execution_path_equivalence.py` - Execution tracing utilities
- `src/backend/tests/unit/graph/test_event_recorder.py` - Event recorder tests
- `src/backend/tests/unit/graph/test_graph_mutation_events.py` - Mutation event tests

### Modified Files
- `src/lfx/src/lfx/graph/graph/base.py` - Added observer pattern, event emission
- `src/lfx/src/lfx/graph/graph/runnable_vertices_manager.py` - Made methods async
- `src/lfx/src/lfx/components/logic/loop.py` - Improved dependency synchronization
- `src/lfx/src/lfx/custom/custom_component/component.py` - Better error handling
- `src/lfx/src/lfx/custom/custom_component/custom_component.py` - Use mark_branch_sync
- `src/lfx/src/lfx/custom/validate.py` - TYPE_CHECKING block support
- `pyproject.toml` - Added marimo dependency for debugging notebooks

## Benefits

1. **Debugging**: Comprehensive visibility into graph execution state changes
2. **Testing**: Better test coverage with execution path validation
3. **Reliability**: Synchronized dependency structures prevent bugs
4. **Performance**: Zero overhead when debugging is not active
5. **Extensibility**: Easy to add new event types and observers

## Testing

- ✅ All existing tests pass
- ✅ New execution path validation tests pass
- ✅ Event system tests pass
- ✅ Loop component tests pass with improved dependency handling

## Breaking Changes

None - This is a purely additive change. The event system is opt-in and has zero overhead when not used.

## Future Work

- [ ] Add more event types (vertex execution start/end, memory updates, etc.)
- [ ] Create visualization tools for event recordings
- [ ] Add event filtering and querying capabilities
- [ ] Integrate with Langflow UI for real-time debugging

44 changes: 44 additions & 0 deletions PR_DESCRIPTION_SHORT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
# Graph Execution Debugging and Event System

## Summary

Introduces a comprehensive event-based debugging system for Langflow graph execution with zero overhead when not in use. Uses a pure observer pattern to track all graph state mutations during execution.

## Key Changes

### 🎯 Event System
- New `GraphMutationEvent` system tracking all graph state changes
- Observer pattern with `register_observer()` / `unregister_observer()`
- Zero overhead fast path when no observers registered
- Serializable events for replay and analysis

### 📊 Event Recording
- `EventRecorder` captures all graph mutations
- `EventBasedRecording` with analysis methods (queue evolution, dependency changes, etc.)
- Save/load recordings for later analysis

### 🔧 Graph Improvements
- **Loop Component**: Synchronized `run_predecessors` and `run_map` dependencies
- **Graph Manager**: Made `remove_from_predecessors()` async, added `mark_branch_sync()` for sync contexts
- **Component Validation**: Better `TYPE_CHECKING` block handling

### 🧪 Testing
- Execution path validation tests (`async_start()` vs `arun()` equivalence)
- Event system tests
- Comprehensive test coverage

## Usage

```python
from lfx.debug.event_recorder import record_graph_with_events

recording = await record_graph_with_events(graph, "My Flow")
recording.show_summary()
recording.get_queue_evolution()
recording.save("recording.pkl")
```

## Breaking Changes

None - purely additive, opt-in feature.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ dev = [
"pytest-timeout>=2.3.1",
"pyyaml>=6.0.2",
"pyleak>=0.1.14",
"marimo>=0.17.0",
]

[tool.uv.sources]
Expand Down
63 changes: 63 additions & 0 deletions src/backend/tests/unit/graph/test_event_recorder.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
"""Tests for event-based graph recorder (pure observer pattern)."""

from __future__ import annotations

import pytest


@pytest.mark.asyncio
async def test_event_recorder_captures_all_mutations():
"""Test that event recorder captures all graph mutations."""
import json
from pathlib import Path

from lfx.debug.event_recorder import record_graph_with_events
from lfx.graph.graph.base import Graph

test_file = Path("src/backend/tests/data/LoopTest.json")
data = json.loads(test_file.read_text())
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify test data file path reliability.

The hard-coded relative path "src/backend/tests/data/LoopTest.json" assumes execution from repository root. This might break if tests are run from different directories.

Verify the test data file exists and the path resolves correctly:

Also applies to: 44-45


🏁 Script executed:

#!/bin/bash
# Verify test data file exists
fd LoopTest.json src/backend/tests/

Length of output: 36


Now I need to examine the test file itself and any pytest configuration:Now let me examine the test file and conftest to understand how it's actually used:

Let me examine the actual test file context and project configuration:Now let me examine the actual test file and pytest configuration:

Based on the comprehensive analysis, I now have sufficient information to provide a final response. The hard-coded relative path is indeed a valid concern, but the original review comment needs clarification on whether this is specific to Langflow's testing conventions.

Use Path(__file__).parent for test-file-relative paths instead of hard-coded repository paths.

Using Path(__file__).parent is the recommended approach to retrieve files relative to the test module. The current code at lines 17-18 and 44-45 uses Path("src/backend/tests/data/LoopTest.json"), which assumes the working directory is the repository root and breaks if tests run from different directories.

Refactor to:

test_file = Path(__file__).parent.parent.parent / "data" / "LoopTest.json"

This ensures the path resolves correctly regardless of where pytest is invoked from.


🏁 Script executed:

#!/bin/bash
# Examine test_event_recorder.py to understand path usage
head -50 src/backend/tests/unit/graph/test_event_recorder.py

Length of output: 1475


🏁 Script executed:

#!/bin/bash
# Check for pytest configuration
if [ -f pyproject.toml ]; then
  echo "=== pyproject.toml ===" 
  grep -A 10 "\[tool.pytest" pyproject.toml
fi
if [ -f pytest.ini ]; then
  echo "=== pytest.ini ==="
  cat pytest.ini
fi

Length of output: 422


🏁 Script executed:

#!/bin/bash
# Check if conftest.py exists and examine it
find . -name conftest.py -type f | head -5

Length of output: 103


🏁 Script executed:

#!/bin/bash
# View the test_event_recorder.py file around lines 17-18 and 44-45
sed -n '1,60p' src/backend/tests/unit/graph/test_event_recorder.py

Length of output: 1705


Use Path(__file__).parent for test-file-relative paths instead of hard-coded repository paths.

The hard-coded relative path Path("src/backend/tests/data/LoopTest.json") assumes execution from the repository root. While pytest's testpaths configuration points to test directories, it does not change the working directory—tests execute from the repository root by default. This becomes fragile if tests run from subdirectories or if the working directory changes during execution.

Refactor both occurrences (lines 17-18 and 44-45) to use:

test_file = Path(__file__).parent.parent.parent / "data" / "LoopTest.json"

This makes the path relative to the test file itself, ensuring it resolves correctly regardless of where pytest is invoked.

🤖 Prompt for AI Agents
In src/backend/tests/unit/graph/test_event_recorder.py around lines 17-18 and
44-45, the test currently uses a hard-coded repository-relative path for
LoopTest.json which breaks if tests run from a different working directory;
change both occurrences to build the path relative to the test file itself by
using Path(__file__).parent.parent.parent combined with the "data/LoopTest.json"
segments so the test always resolves the fixture file regardless of where pytest
is invoked.


graph = Graph.from_payload(data["data"])

recording = await record_graph_with_events(graph, "Test")

# Should capture many events
assert len(recording.events) > 100

# Should have queue and vertex events
queue_events = recording.get_events_by_type("queue_extended")
assert len(queue_events) > 0

vertex_events = recording.get_events_by_type("vertex_marked")
assert len(vertex_events) > 0


@pytest.mark.asyncio
async def test_queue_evolution_tracking():
"""Test queue evolution analysis."""
import json
from pathlib import Path

from lfx.debug.event_recorder import record_graph_with_events
from lfx.graph.graph.base import Graph

test_file = Path("src/backend/tests/data/LoopTest.json")
data = json.loads(test_file.read_text())

graph = Graph.from_payload(data["data"])
recording = await record_graph_with_events(graph, "Test")

queue_evo = recording.get_queue_evolution()

# Should have queue evolution entries
assert len(queue_evo) > 0

# Each entry should have required fields
first = queue_evo[0]
assert "step" in first
assert "queue" in first
assert "changes" in first


if __name__ == "__main__":
pytest.main([__file__, "-v", "-s"])
51 changes: 51 additions & 0 deletions src/backend/tests/unit/graph/test_graph_mutation_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Tests for graph mutation event system."""

from __future__ import annotations

from typing import TYPE_CHECKING

import pytest

if TYPE_CHECKING:
from lfx.debug.events import GraphMutationEvent


@pytest.mark.asyncio
async def test_queue_operations_emit_events():
"""Test queue operations emit before/after events."""
from lfx.graph.graph.base import Graph

events = []

async def capture(event: GraphMutationEvent):
events.append(event)

graph = Graph()
graph.register_observer(capture)
await graph.extend_run_queue(["v1", "v2"])

assert len(events) == 2
assert events[0].timing == "before"
assert events[1].timing == "after"


@pytest.mark.asyncio
async def test_dependency_updates_both_structures():
"""Test add_dynamic_dependency updates both structures."""
from lfx.graph.graph.base import Graph

graph = Graph()
await graph.add_dynamic_dependency("v1", "v2")

assert "v2" in graph.run_manager.run_predecessors["v1"]
assert "v1" in graph.run_manager.run_map["v2"]


@pytest.mark.asyncio
async def test_fast_path_no_overhead():
"""Test zero overhead without observers."""
from lfx.graph.graph.base import Graph

graph = Graph()
await graph.extend_run_queue(["v1"])
assert graph._mutation_step == 0
30 changes: 28 additions & 2 deletions src/lfx/src/lfx/components/logic/loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,21 @@ def item_output(self) -> Data:
self.aggregated_output()
self.update_ctx({f"{self._id}_index": current_index + 1})

# Now we need to update the dependencies for the next run
# Update dependencies - call sync version for now
# TODO: Make this async when component output methods support async
self.update_dependency()

return current_item

def update_dependency(self):
"""Update loop dependencies using centralized graph API (sync).

This ensures run_predecessors and run_map stay synchronized.
Uses the fast path (no events) since called from sync context.
"""
item_dependency_id = self.get_incoming_edge_by_target_param("item")
if item_dependency_id not in self.graph.run_manager.run_predecessors[self._id]:
if item_dependency_id and item_dependency_id not in self.graph.run_manager.run_predecessors[self._id]:
# Call the fast path directly (no events since sync)
self.graph.run_manager.run_predecessors[self._id].append(item_dependency_id)
# CRITICAL: Also update run_map so remove_from_predecessors() works correctly
# run_map[predecessor] = list of vertices that depend on predecessor
Expand Down Expand Up @@ -127,3 +135,21 @@ def aggregated_output(self) -> list[Data]:
aggregated.append(loop_input)
self.update_ctx({f"{self._id}_aggregated": aggregated})
return aggregated

def reset_loop_state(self) -> None:
"""Reset loop internal state for fresh execution.

This should be called before starting a new independent iteration
of the graph to ensure the loop starts from a clean state.

This method clears all loop-specific context variables including:
- initialization flag
- current index
- aggregated results
- stored data
"""
loop_id = self._id
self.ctx.pop(f"{loop_id}_initialized", None)
self.ctx.pop(f"{loop_id}_index", None)
self.ctx.pop(f"{loop_id}_aggregated", None)
self.ctx.pop(f"{loop_id}_data", None)
12 changes: 7 additions & 5 deletions src/lfx/src/lfx/custom/custom_component/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,16 @@ def set_class_code(self) -> None:
try:
module = inspect.getmodule(self.__class__)
if module is None:
msg = "Could not find module for class"
raise ValueError(msg)
# Module not found - likely defined in notebook or REPL
self._code = f"# Component defined in {self.__class__.__name__}"
return

class_code = inspect.getsource(module)
self._code = class_code
except (OSError, TypeError) as e:
msg = f"Could not find source code for {self.__class__.__name__}"
raise ValueError(msg) from e
except (OSError, TypeError):
# Source code not available (e.g., defined in notebook, REPL, or dynamically)
# This is fine - just use a placeholder
self._code = f"# Component {self.__class__.__name__} (source code not available)"

def set(self, **kwargs):
"""Connects the component to other components or sets parameters and attributes.
Expand Down
Loading
Loading