Skip to content

Commit 937cffa

Browse files
Copilotberndverst
andcommitted
Add comprehensive entity documentation and update README
Co-authored-by: berndverst <[email protected]>
1 parent 70ebfa1 commit 937cffa

File tree

2 files changed

+351
-5
lines changed

2 files changed

+351
-5
lines changed

README.md

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,10 @@ Orchestrations can wait for external events using the `wait_for_external_event`
121121

122122
Durable entities are stateful objects that can maintain state across multiple operations. Entities support operations that can read and modify the entity's state. Each entity has a unique entity ID and maintains its state independently.
123123

124+
The Python SDK supports both function-based and class-based entity implementations:
125+
126+
#### Function-based entities (simple)
127+
124128
```python
125129
# Define an entity function
126130
def counter_entity(ctx: task.EntityContext, input):
@@ -133,21 +137,78 @@ def counter_entity(ctx: task.EntityContext, input):
133137
return ctx.get_state() or 0
134138

135139
# Register the entity with the worker
136-
worker.add_named_entity("Counter", counter_entity)
140+
worker._registry.add_named_entity("Counter", counter_entity)
141+
```
142+
143+
#### Class-based entities (advanced)
144+
145+
```python
146+
import durabletask as dt
147+
148+
class CounterEntity(dt.EntityBase):
149+
def increment(self, value: int = 1) -> int:
150+
current = self.get_state() or 0
151+
new_value = current + value
152+
self.set_state(new_value)
153+
return new_value
154+
155+
def get(self) -> int:
156+
return self.get_state() or 0
157+
158+
def reset(self) -> int:
159+
self.set_state(0)
160+
return 0
161+
162+
# Register class-based entity
163+
worker._registry.add_named_entity("Counter", CounterEntity)
164+
```
165+
166+
#### Client operations with structured IDs
167+
168+
```python
169+
# Use structured entity IDs (recommended)
170+
counter_id = dt.EntityInstanceId("Counter", "my-counter")
137171

138172
# Signal an entity from an orchestrator
139-
yield ctx.signal_entity("Counter@my-counter", "increment", input=5)
173+
yield ctx.signal_entity(counter_id, "increment", input=5)
140174

141175
# Or signal an entity directly from a client
142-
client.signal_entity("Counter@my-counter", "increment", input=10)
176+
client.signal_entity(counter_id, "increment", input=10)
143177

144178
# Query entity state
145-
entity_state = client.get_entity("Counter@my-counter", include_state=True)
179+
entity_state = client.get_entity(counter_id, include_state=True)
146180
if entity_state and entity_state.exists:
147181
print(f"Current count: {entity_state.serialized_state}")
182+
183+
# Query multiple entities
184+
query = dt.EntityQuery(instance_id_starts_with="Counter@", include_state=True)
185+
results = client.query_entities(query)
186+
```
187+
188+
#### Entity-to-entity communication
189+
190+
Entities can signal other entities and start orchestrations:
191+
192+
```python
193+
class NotificationEntity(dt.EntityBase):
194+
def send_notification(self, data):
195+
# Process notification
196+
notifications = self.get_state() or {"count": 0}
197+
notifications["count"] += 1
198+
self.set_state(notifications)
199+
200+
# Signal another entity
201+
counter_id = dt.EntityInstanceId("Counter", f"user-{data['user_id']}")
202+
self.signal_entity(counter_id, "increment")
203+
204+
# Start an orchestration
205+
return self.start_new_orchestration("process_notification", input=data)
148206
```
149207

150-
You can find the full sample [here](./examples/durable_entities.py).
208+
You can find comprehensive examples in:
209+
- [Basic entities](./examples/durable_entities.py)
210+
- [Class-based entities](./examples/class_based_entities.py)
211+
- [Complete guide](./docs/entities.md)
151212

152213
### Continue-as-new (TODO)
153214

docs/entities.md

Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
# Durable Entities Guide
2+
3+
This guide covers the comprehensive durable entities support in the Python SDK, bringing feature parity with other Durable Task SDKs.
4+
5+
## What are Durable Entities?
6+
7+
Durable entities are stateful objects that can maintain state across multiple operations. Each entity has a unique entity ID and can handle various operations that read and modify its state. Entities are accessed using the format `EntityType@EntityKey` (e.g., `Counter@user1`).
8+
9+
## Key Features
10+
11+
### Entity Functions (Basic Implementation)
12+
13+
Register entity functions that handle operations and maintain state:
14+
15+
```python
16+
import durabletask as dt
17+
18+
def counter_entity(ctx: dt.EntityContext, input):
19+
if ctx.operation_name == "increment":
20+
current_count = ctx.get_state() or 0
21+
new_count = current_count + (input or 1)
22+
ctx.set_state(new_count)
23+
return new_count
24+
elif ctx.operation_name == "get":
25+
return ctx.get_state() or 0
26+
27+
# Register with worker
28+
worker = TaskHubGrpcWorker()
29+
worker._registry.add_named_entity("Counter", counter_entity)
30+
```
31+
32+
### Class-Based Entities (Advanced Implementation)
33+
34+
For more complex entities, use the `EntityBase` class with method-based dispatch:
35+
36+
```python
37+
import durabletask as dt
38+
39+
class CounterEntity(dt.EntityBase):
40+
def __init__(self):
41+
super().__init__()
42+
self._state = 0
43+
44+
def increment(self, value: int = 1) -> int:
45+
"""Increment the counter by the specified value."""
46+
current = self.get_state() or 0
47+
new_value = current + value
48+
self.set_state(new_value)
49+
return new_value
50+
51+
def get(self) -> int:
52+
"""Get the current counter value."""
53+
return self.get_state() or 0
54+
55+
def reset(self) -> int:
56+
"""Reset the counter to zero."""
57+
self.set_state(0)
58+
return 0
59+
60+
# Register class-based entity
61+
worker._registry.add_named_entity("Counter", CounterEntity)
62+
```
63+
64+
### Client Operations
65+
66+
Signal entities, query state, and manage entity storage:
67+
68+
```python
69+
# Create client
70+
client = TaskHubGrpcClient()
71+
72+
# Signal an entity using string ID
73+
client.signal_entity("Counter@my-counter", "increment", input=5)
74+
75+
# Signal an entity using structured ID (recommended)
76+
counter_id = dt.EntityInstanceId("Counter", "my-counter")
77+
client.signal_entity(counter_id, "increment", input=5)
78+
79+
# Query entity state
80+
entity_state = client.get_entity(counter_id, include_state=True)
81+
if entity_state and entity_state.exists:
82+
print(f"Counter value: {entity_state.serialized_state}")
83+
84+
# Query multiple entities
85+
query = dt.EntityQuery(instance_id_starts_with="Counter@", include_state=True)
86+
results = client.query_entities(query)
87+
print(f"Found {len(results.entities)} counter entities")
88+
89+
# Clean entity storage
90+
removed, released, token = client.clean_entity_storage()
91+
```
92+
93+
### Orchestration Integration
94+
95+
Signal entities from orchestrations:
96+
97+
```python
98+
def my_orchestrator(ctx: dt.OrchestrationContext, input):
99+
# Signal entities (fire-and-forget)
100+
counter_id = dt.EntityInstanceId("Counter", "global")
101+
yield ctx.signal_entity(counter_id, "increment", input=5)
102+
103+
cart_id = dt.EntityInstanceId("ShoppingCart", "user1")
104+
yield ctx.signal_entity(cart_id, "add_item",
105+
input={"name": "Apple", "price": 1.50})
106+
return "Entity operations completed"
107+
```
108+
109+
### Entity-to-Entity Communication
110+
111+
Entities can signal other entities and start orchestrations:
112+
113+
```python
114+
class NotificationEntity(dt.EntityBase):
115+
def send_notification(self, data):
116+
user_id = data["user_id"]
117+
message = data["message"]
118+
119+
# Store notification
120+
notifications = self.get_state() or {"notifications": []}
121+
notifications["notifications"].append({
122+
"user_id": user_id,
123+
"message": message,
124+
"timestamp": datetime.utcnow().isoformat()
125+
})
126+
self.set_state(notifications)
127+
128+
# Signal user's notification counter
129+
counter_id = dt.EntityInstanceId("Counter", f"notifications-{user_id}")
130+
self.signal_entity(counter_id, "increment", input=1)
131+
132+
# Start a notification processing workflow
133+
workflow_id = self.start_new_orchestration(
134+
"process_notification",
135+
input={"user_id": user_id, "message": message}
136+
)
137+
138+
return workflow_id
139+
```
140+
141+
## Entity ID Structure
142+
143+
Use `EntityInstanceId` for type-safe entity references:
144+
145+
```python
146+
# Create structured entity ID
147+
entity_id = dt.EntityInstanceId("Counter", "user123")
148+
print(entity_id.name) # "Counter"
149+
print(entity_id.key) # "user123"
150+
print(str(entity_id)) # "Counter@user123"
151+
152+
# Parse from string
153+
parsed_id = dt.EntityInstanceId.from_string("ShoppingCart@cart1")
154+
```
155+
156+
## Error Handling
157+
158+
Handle entity operation failures with specialized exceptions:
159+
160+
```python
161+
try:
162+
client.signal_entity("NonExistent@entity", "operation")
163+
except dt.EntityOperationFailedException as ex:
164+
print(f"Entity operation failed: {ex.failure_details.message}")
165+
print(f"Failed entity: {ex.entity_id}")
166+
print(f"Failed operation: {ex.operation_name}")
167+
```
168+
169+
## Entity Context Features
170+
171+
The `EntityContext` provides rich functionality:
172+
173+
```python
174+
def advanced_entity(ctx: dt.EntityContext, input):
175+
# Access entity information
176+
print(f"Entity ID: {ctx.instance_id}")
177+
print(f"Entity name: {ctx.entity_id.name}")
178+
print(f"Entity key: {ctx.entity_id.key}")
179+
print(f"Operation: {ctx.operation_name}")
180+
print(f"Is new: {ctx.is_new_entity}")
181+
182+
# State management
183+
current_state = ctx.get_state()
184+
ctx.set_state({"updated": True, "input": input})
185+
186+
# Signal other entities
187+
ctx.signal_entity("Logger@system", "log",
188+
input=f"Operation {ctx.operation_name} executed")
189+
190+
# Start orchestrations
191+
workflow_id = ctx.start_new_orchestration("cleanup_workflow")
192+
193+
return {"workflow_id": workflow_id}
194+
```
195+
196+
## Best Practices
197+
198+
### 1. Use Structured Entity IDs
199+
200+
```python
201+
# ✅ Good - Type-safe and clear
202+
counter_id = dt.EntityInstanceId("Counter", "user123")
203+
client.signal_entity(counter_id, "increment")
204+
205+
# ❌ Avoid - Error-prone string concatenation
206+
client.signal_entity("Counter@user123", "increment")
207+
```
208+
209+
### 2. Implement Rich Entity Classes
210+
211+
```python
212+
# ✅ Good - Clear separation of concerns
213+
class ShoppingCartEntity(dt.EntityBase):
214+
def add_item(self, item: dict) -> int:
215+
# Validation
216+
if not item.get("name") or not item.get("price"):
217+
raise ValueError("Item must have name and price")
218+
219+
# Business logic
220+
cart = self.get_state() or {"items": []}
221+
cart["items"].append(item)
222+
self.set_state(cart)
223+
224+
return len(cart["items"])
225+
226+
def get_total(self) -> float:
227+
cart = self.get_state() or {"items": []}
228+
return sum(item["price"] for item in cart["items"])
229+
```
230+
231+
### 3. Handle State Initialization
232+
233+
```python
234+
class StatefulEntity(dt.EntityBase):
235+
def __init__(self):
236+
super().__init__()
237+
# Set default state structure
238+
self._state = {"initialized": True, "value": 0}
239+
240+
def ensure_initialized(self):
241+
if not self.get_state():
242+
self.set_state({"initialized": True, "value": 0})
243+
```
244+
245+
### 4. Use Type Hints
246+
247+
```python
248+
from typing import Dict, List, Optional
249+
250+
class TypedEntity(dt.EntityBase):
251+
def process_order(self, order_data: Dict[str, any]) -> str:
252+
"""Process an order and return order ID."""
253+
order_id = f"order-{len(self.get_orders())}"
254+
self.add_order(order_data)
255+
return order_id
256+
257+
def get_orders(self) -> List[Dict]:
258+
"""Get all orders."""
259+
state = self.get_state() or {"orders": []}
260+
return state["orders"]
261+
```
262+
263+
## Examples
264+
265+
- **Basic entities**: See [`examples/durable_entities.py`](examples/durable_entities.py)
266+
- **Class-based entities**: See [`examples/class_based_entities.py`](examples/class_based_entities.py)
267+
268+
## Comparison with .NET Implementation
269+
270+
This Python implementation provides feature parity with the .NET DurableTask SDK:
271+
272+
| Feature | .NET | Python | Status |
273+
|---------|------|--------|--------|
274+
| Function-based entities ||| Complete |
275+
| Class-based entities ||| Complete |
276+
| Method dispatch ||| Complete |
277+
| Structured entity IDs ||| Complete |
278+
| Entity-to-entity signals ||| Complete |
279+
| Orchestration starting ||| Complete |
280+
| State management ||| Complete |
281+
| Error handling ||| Complete |
282+
| Client operations ||| Complete |
283+
| Entity locking ||| Planned |
284+
285+
The Python implementation follows the same patterns and provides equivalent functionality to ensure consistency across Durable Task SDKs.

0 commit comments

Comments
 (0)