Skip to content

Commit 4f98e80

Browse files
authored
fix: Fix for unhashable workflow subclasses (#177)
1 parent b2dcce7 commit 4f98e80

File tree

8 files changed

+185
-58
lines changed

8 files changed

+185
-58
lines changed

AGENTS.md

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
# LlamaIndex Workflows - Claude Development Guide
2+
3+
## Project Overview
4+
This is the LlamaIndex Workflows library - an event-driven, async-first framework for orchestrating complex AI applications and multi-step processes.
5+
6+
## Key Technologies
7+
- Python 3.9+
8+
- AsyncIO (async/await)
9+
- Pydantic for data models
10+
- Starlette for web server
11+
- Uvicorn for ASGI serving
12+
13+
## Development Commands
14+
15+
### Testing
16+
```bash
17+
# Run all tests
18+
uv run pytest
19+
20+
# Run tests with coverage
21+
uv run pytest --cov=src/workflows --cov-report=html
22+
23+
# Run specific test files
24+
uv run pytest tests/test_server.py tests/test_server_utils.py
25+
26+
# Run tests in verbose mode
27+
uv run pytest -v
28+
```
29+
30+
### Linting & Formatting
31+
```bash
32+
# Run pre-commit hooks
33+
uv run pre-commit run -a
34+
```
35+
36+
## Project Structure
37+
- `src/workflows/` - Main library code
38+
- `src/workflows/server/` - Web server implementation
39+
- `tests/` - Test suite
40+
- `examples/` - Usage examples
41+
42+
## Key Components
43+
- **Workflow** - Main orchestration class
44+
- **Context** - State management across workflow steps
45+
- **Events** - Event-driven communication between steps
46+
- **WorkflowServer** - HTTP server for serving workflows as web services
47+
48+
## Notes for Claude
49+
- Always run tests after making changes: `uv run pytest`
50+
- Never use classes for tests, only use pytest functions
51+
- Always annotate with types function arguments and return values
52+
- The project uses async/await extensively
53+
- Context serialization requires specific JSON format for globals
54+
55+
## Autonomous Operation
56+
57+
The following rules apply if you are running in an isolated sandbox environment and have tools to commit and push changes to git
58+
59+
Make sure to install uv as the package manager. Development commands rely on it.
60+
61+
```bash
62+
curl -fsSL https://astral.sh/uv/install.sh | sh
63+
```
64+
65+
Always run test tests and pre-commit commands before committing. They run very fast and are not verbose.
66+
67+
Tests:
68+
69+
```bash
70+
uv run pytest -nauto --timeout=1
71+
```
72+
73+
Linting, typechecking, and formatting:
74+
```bash
75+
uv run pre-commit run -a
76+
```

CLAUDE.md

Lines changed: 0 additions & 53 deletions
This file was deleted.

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
AGENTS.md

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ dev = [
1818

1919
[project]
2020
name = "llama-index-workflows"
21-
version = "2.9.0"
21+
version = "2.9.1"
2222
description = "An event-driven, async-first, step-based way to control the execution flow of AI applications like Agents."
2323
readme = "README.md"
2424
license = "MIT"
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
from __future__ import annotations
2+
3+
from typing import Callable, Generic, TypeVar, overload
4+
import weakref
5+
6+
K = TypeVar("K")
7+
V = TypeVar("V")
8+
9+
10+
class _IdentityWeakRef(weakref.ref, Generic[K]):
11+
__slots__ = ("_hash",)
12+
13+
_hash: int
14+
15+
def __new__(
16+
cls, obj: K, callback: Callable[[_IdentityWeakRef[K]], None] | None = None
17+
) -> _IdentityWeakRef[K]:
18+
self = super().__new__(cls, obj, callback)
19+
self._hash = id(
20+
obj
21+
) # cache identity-based hash; works even if obj is unhashable
22+
return self
23+
24+
def __hash__(self) -> int:
25+
return self._hash
26+
27+
def __eq__(self, other: object) -> bool:
28+
if not isinstance(other, _IdentityWeakRef):
29+
return NotImplemented
30+
return self() is other()
31+
32+
33+
class IdentityWeakKeyDict(Generic[K, V]):
34+
_d: dict[_IdentityWeakRef[K], V]
35+
36+
def __init__(self) -> None:
37+
self._d = {}
38+
39+
def _mk(self, obj: K) -> _IdentityWeakRef[K]:
40+
def _cb(wr: _IdentityWeakRef[K]) -> None:
41+
self._d.pop(wr)
42+
43+
return _IdentityWeakRef(obj, _cb)
44+
45+
def __setitem__(self, obj: K, value: V) -> None:
46+
self._d[self._mk(obj)] = value
47+
48+
def __getitem__(self, obj: K) -> V:
49+
return self._d[_IdentityWeakRef(obj)]
50+
51+
@overload
52+
def get(self, obj: K) -> V | None: ...
53+
54+
@overload
55+
def get(self, obj: K, default: V) -> V: ...
56+
57+
def get(self, obj: K, default: V | None = None) -> V | None:
58+
return self._d.get(_IdentityWeakRef(obj), default)
59+
60+
def __contains__(self, obj: K) -> bool:
61+
return _IdentityWeakRef(obj) in self._d
62+
63+
def __delitem__(self, obj: K) -> None:
64+
del self._d[_IdentityWeakRef(obj)]

src/workflows/runtime/workflow_registry.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from threading import Lock
2-
from weakref import WeakKeyDictionary
32
from dataclasses import dataclass
43
from typing import Optional
4+
from workflows.runtime.types._identity_weak_ref import IdentityWeakKeyDict
55
from workflows.runtime.types.plugin import (
66
ControlLoopFunction,
77
Plugin,
@@ -24,9 +24,9 @@ class WorkflowPluginRegistry:
2424
def __init__(self) -> None:
2525
# Map each workflow instance to its plugin registrations.
2626
# Weakly references workflow keys so entries are GC'd when workflows are.
27-
self.workflows: WeakKeyDictionary[
27+
self.workflows: IdentityWeakKeyDict[
2828
Workflow, dict[type[Plugin], RegisteredWorkflow]
29-
] = WeakKeyDictionary()
29+
] = IdentityWeakKeyDict()
3030
self.lock = Lock()
3131
self.run_contexts: dict[str, RegisteredRunContext] = {}
3232

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
from __future__ import annotations
2+
3+
import gc
4+
import weakref
5+
6+
import pytest
7+
8+
from workflows.runtime.types._identity_weak_ref import IdentityWeakKeyDict
9+
10+
11+
class Unhashable:
12+
def __hash__(self) -> int:
13+
raise TypeError("Unhashable")
14+
15+
16+
def test_identity_weak_key_dict_removes_entry_when_object_unreferenced() -> None:
17+
with pytest.raises(TypeError):
18+
hash(Unhashable())
19+
20+
d: IdentityWeakKeyDict[Unhashable, str] = IdentityWeakKeyDict()
21+
22+
obj = Unhashable()
23+
d[obj] = "value"
24+
25+
# While the object is strongly referenced, it should be present
26+
assert obj in d
27+
assert d.get(obj) == "value"
28+
29+
# Keep a weak reference to verify collection happened
30+
w = weakref.ref(obj)
31+
32+
# Drop strong reference and force collection
33+
del obj
34+
gc.collect()
35+
36+
# Object should be collected and the dict entry removed via callback
37+
assert w() is None
38+
assert d._d == {}

tests/test_workflow.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -795,6 +795,7 @@ def one(self, ev: MyStart) -> MyStop:
795795

796796
@pytest.mark.asyncio
797797
async def test_workflow_instances_garbage_collected_after_completion() -> None:
798+
# test for memory leaks
798799
class TinyWorkflow(Workflow):
799800
@step
800801
async def only(self, ev: StartEvent) -> StopEvent:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)