stemtrace 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- stemtrace/__init__.py +173 -0
- stemtrace/core/__init__.py +15 -0
- stemtrace/core/events.py +67 -0
- stemtrace/core/exceptions.py +25 -0
- stemtrace/core/graph.py +348 -0
- stemtrace/core/ports.py +42 -0
- stemtrace/library/__init__.py +1 -0
- stemtrace/library/bootsteps.py +169 -0
- stemtrace/library/config.py +55 -0
- stemtrace/library/scrubbing.py +195 -0
- stemtrace/library/signals.py +434 -0
- stemtrace/library/transports/__init__.py +36 -0
- stemtrace/library/transports/memory.py +33 -0
- stemtrace/library/transports/redis.py +92 -0
- stemtrace/py.typed +0 -0
- stemtrace/server/__init__.py +24 -0
- stemtrace/server/__main__.py +136 -0
- stemtrace/server/api/__init__.py +29 -0
- stemtrace/server/api/routes.py +270 -0
- stemtrace/server/api/schemas.py +136 -0
- stemtrace/server/api/websocket.py +28 -0
- stemtrace/server/consumer.py +133 -0
- stemtrace/server/fastapi/__init__.py +13 -0
- stemtrace/server/fastapi/auth.py +77 -0
- stemtrace/server/fastapi/extension.py +121 -0
- stemtrace/server/fastapi/router.py +42 -0
- stemtrace/server/store.py +260 -0
- stemtrace/server/ui/__init__.py +5 -0
- stemtrace/server/ui/frontend/biome.json +58 -0
- stemtrace/server/ui/frontend/dist/assets/index-CCs7iO1n.css +1 -0
- stemtrace/server/ui/frontend/dist/assets/index-Dao0_Q6x.js +14 -0
- stemtrace/server/ui/frontend/dist/index.html +17 -0
- stemtrace/server/ui/frontend/index.html +16 -0
- stemtrace/server/ui/frontend/package-lock.json +4049 -0
- stemtrace/server/ui/frontend/package.json +46 -0
- stemtrace/server/ui/frontend/playwright.config.ts +40 -0
- stemtrace/server/ui/frontend/postcss.config.js +5 -0
- stemtrace/server/ui/frontend/src/api/client.ts +173 -0
- stemtrace/server/ui/frontend/src/api/queries.ts +151 -0
- stemtrace/server/ui/frontend/src/components/DateRangePicker.tsx +244 -0
- stemtrace/server/ui/frontend/src/components/Filters.tsx +116 -0
- stemtrace/server/ui/frontend/src/components/TaskGraph.tsx +453 -0
- stemtrace/server/ui/frontend/src/components/TaskList.tsx +172 -0
- stemtrace/server/ui/frontend/src/components/TaskStateBadge.tsx +27 -0
- stemtrace/server/ui/frontend/src/components/TaskTimeline.tsx +144 -0
- stemtrace/server/ui/frontend/src/components/TimelineBar.tsx +224 -0
- stemtrace/server/ui/frontend/src/components/Tooltip.tsx +54 -0
- stemtrace/server/ui/frontend/src/hooks/WebSocketContext.tsx +143 -0
- stemtrace/server/ui/frontend/src/hooks/useInfiniteScroll.ts +56 -0
- stemtrace/server/ui/frontend/src/index.css +104 -0
- stemtrace/server/ui/frontend/src/main.tsx +44 -0
- stemtrace/server/ui/frontend/src/routes/__root.tsx +103 -0
- stemtrace/server/ui/frontend/src/routes/graph.$rootId.tsx +51 -0
- stemtrace/server/ui/frontend/src/routes/graphs.tsx +186 -0
- stemtrace/server/ui/frontend/src/routes/index.tsx +119 -0
- stemtrace/server/ui/frontend/src/routes/registry.tsx +211 -0
- stemtrace/server/ui/frontend/src/routes/tasks.$taskId.tsx +313 -0
- stemtrace/server/ui/frontend/src/utils/format.ts +38 -0
- stemtrace/server/ui/frontend/tests/graphs.spec.ts +485 -0
- stemtrace/server/ui/frontend/tests/registry.spec.ts +117 -0
- stemtrace/server/ui/frontend/tests/task-detail.spec.ts +147 -0
- stemtrace/server/ui/frontend/tests/tasks.spec.ts +92 -0
- stemtrace/server/ui/frontend/tsconfig.json +32 -0
- stemtrace/server/ui/frontend/tsconfig.tsbuildinfo +1 -0
- stemtrace/server/ui/frontend/vite.config.ts +30 -0
- stemtrace/server/ui/static.py +56 -0
- stemtrace/server/websocket.py +99 -0
- stemtrace-0.1.0.dist-info/METADATA +428 -0
- stemtrace-0.1.0.dist-info/RECORD +72 -0
- stemtrace-0.1.0.dist-info/WHEEL +4 -0
- stemtrace-0.1.0.dist-info/entry_points.txt +2 -0
- stemtrace-0.1.0.dist-info/licenses/LICENSE +21 -0
stemtrace/__init__.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""stemtrace: A lightweight Celery task flow visualizer.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
import stemtrace
|
|
5
|
+
|
|
6
|
+
stemtrace.init(app)
|
|
7
|
+
|
|
8
|
+
# Introspection
|
|
9
|
+
stemtrace.is_initialized() # -> bool
|
|
10
|
+
stemtrace.get_config() # -> StemtraceConfig | None
|
|
11
|
+
stemtrace.get_transport() # -> EventTransport | None
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from typing import TYPE_CHECKING
|
|
15
|
+
|
|
16
|
+
from stemtrace.core.events import TaskEvent, TaskState
|
|
17
|
+
from stemtrace.core.exceptions import ConfigurationError
|
|
18
|
+
from stemtrace.core.graph import TaskGraph, TaskNode
|
|
19
|
+
from stemtrace.core.ports import EventTransport
|
|
20
|
+
from stemtrace.library.bootsteps import register_bootsteps
|
|
21
|
+
from stemtrace.library.config import StemtraceConfig, _reset_config, set_config
|
|
22
|
+
from stemtrace.library.config import get_config as _get_config
|
|
23
|
+
from stemtrace.library.signals import connect_signals
|
|
24
|
+
from stemtrace.library.transports import get_transport as _get_transport
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from celery import Celery
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ConfigurationError",
|
|
32
|
+
"StemtraceConfig",
|
|
33
|
+
"TaskEvent",
|
|
34
|
+
"TaskGraph",
|
|
35
|
+
"TaskNode",
|
|
36
|
+
"TaskState",
|
|
37
|
+
"__version__",
|
|
38
|
+
"get_config",
|
|
39
|
+
"get_transport",
|
|
40
|
+
"init",
|
|
41
|
+
"is_initialized",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
# Module-level state
|
|
45
|
+
_transport: EventTransport | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def is_initialized() -> bool:
|
|
49
|
+
"""Check if stemtrace has been initialized.
|
|
50
|
+
|
|
51
|
+
Returns:
|
|
52
|
+
True if init() has been called, False otherwise.
|
|
53
|
+
|
|
54
|
+
Example:
|
|
55
|
+
>>> import stemtrace
|
|
56
|
+
>>> stemtrace.is_initialized()
|
|
57
|
+
False
|
|
58
|
+
>>> stemtrace.init(app)
|
|
59
|
+
>>> stemtrace.is_initialized()
|
|
60
|
+
True
|
|
61
|
+
"""
|
|
62
|
+
return _transport is not None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def get_config() -> StemtraceConfig | None:
|
|
66
|
+
"""Get the active stemtrace configuration.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
The configuration if initialized, None otherwise.
|
|
70
|
+
|
|
71
|
+
Example:
|
|
72
|
+
>>> import stemtrace
|
|
73
|
+
>>> stemtrace.init(app, prefix="my_prefix")
|
|
74
|
+
>>> config = stemtrace.get_config()
|
|
75
|
+
>>> config.prefix
|
|
76
|
+
'my_prefix'
|
|
77
|
+
"""
|
|
78
|
+
return _get_config()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_transport() -> EventTransport | None:
|
|
82
|
+
"""Get the active event transport.
|
|
83
|
+
|
|
84
|
+
Useful for testing to verify events are published correctly.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
The transport if initialized, None otherwise.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
>>> import stemtrace
|
|
91
|
+
>>> stemtrace.init(app, transport_url="memory://")
|
|
92
|
+
>>> transport = stemtrace.get_transport()
|
|
93
|
+
>>> # For MemoryTransport, you can access published events
|
|
94
|
+
"""
|
|
95
|
+
return _transport
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def init(
|
|
99
|
+
app: "Celery",
|
|
100
|
+
*,
|
|
101
|
+
transport_url: str | None = None,
|
|
102
|
+
prefix: str = "stemtrace",
|
|
103
|
+
ttl: int = 86400,
|
|
104
|
+
capture_args: bool = True,
|
|
105
|
+
capture_result: bool = True,
|
|
106
|
+
scrub_sensitive_data: bool = True,
|
|
107
|
+
additional_sensitive_keys: frozenset[str] | None = None,
|
|
108
|
+
safe_keys: frozenset[str] | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Initialize stemtrace event tracking.
|
|
111
|
+
|
|
112
|
+
Connects to Celery's task signals and publishes lifecycle events
|
|
113
|
+
to the configured transport (Redis, RabbitMQ, etc.).
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
app: The Celery application instance.
|
|
117
|
+
transport_url: Broker URL for events. If None, uses Celery's broker_url.
|
|
118
|
+
prefix: Key/queue prefix for events.
|
|
119
|
+
ttl: Event TTL in seconds (default: 24 hours).
|
|
120
|
+
capture_args: Whether to capture task args/kwargs (default: True).
|
|
121
|
+
capture_result: Whether to capture task return values (default: True).
|
|
122
|
+
scrub_sensitive_data: Whether to scrub sensitive keys (default: True).
|
|
123
|
+
additional_sensitive_keys: Extra keys to treat as sensitive.
|
|
124
|
+
safe_keys: Keys to never scrub (overrides sensitive).
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ConfigurationError: If no broker URL can be determined.
|
|
128
|
+
|
|
129
|
+
Example:
|
|
130
|
+
>>> from celery import Celery
|
|
131
|
+
>>> import stemtrace
|
|
132
|
+
>>> app = Celery("myapp", broker="redis://localhost:6379/0")
|
|
133
|
+
>>> stemtrace.init(app)
|
|
134
|
+
|
|
135
|
+
# With explicit transport URL:
|
|
136
|
+
>>> stemtrace.init(app, transport_url="redis://events-redis:6379/1")
|
|
137
|
+
|
|
138
|
+
# Disable arg capture:
|
|
139
|
+
>>> stemtrace.init(app, capture_args=False)
|
|
140
|
+
"""
|
|
141
|
+
global _transport
|
|
142
|
+
|
|
143
|
+
url = transport_url or app.conf.broker_url
|
|
144
|
+
if not url:
|
|
145
|
+
raise ConfigurationError(
|
|
146
|
+
"No broker URL available. Either pass transport_url or configure "
|
|
147
|
+
"Celery's broker_url."
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
config = StemtraceConfig(
|
|
151
|
+
transport_url=url,
|
|
152
|
+
prefix=prefix,
|
|
153
|
+
ttl=ttl,
|
|
154
|
+
capture_args=capture_args,
|
|
155
|
+
capture_result=capture_result,
|
|
156
|
+
scrub_sensitive_data=scrub_sensitive_data,
|
|
157
|
+
additional_sensitive_keys=additional_sensitive_keys or frozenset(),
|
|
158
|
+
safe_keys=safe_keys or frozenset(),
|
|
159
|
+
)
|
|
160
|
+
set_config(config)
|
|
161
|
+
|
|
162
|
+
_transport = _get_transport(url, prefix=prefix, ttl=ttl)
|
|
163
|
+
connect_signals(_transport)
|
|
164
|
+
|
|
165
|
+
# Register bootsteps for RECEIVED events (worker-side)
|
|
166
|
+
register_bootsteps(app)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _reset() -> None:
|
|
170
|
+
"""Reset module state. For testing only."""
|
|
171
|
+
global _transport
|
|
172
|
+
_transport = None
|
|
173
|
+
_reset_config()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Core domain layer - pure Python, no external dependencies."""
|
|
2
|
+
|
|
3
|
+
from stemtrace.core.events import TaskEvent, TaskState
|
|
4
|
+
from stemtrace.core.exceptions import ConfigurationError, StemtraceError
|
|
5
|
+
from stemtrace.core.graph import NodeType, TaskGraph, TaskNode
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"ConfigurationError",
|
|
9
|
+
"NodeType",
|
|
10
|
+
"StemtraceError",
|
|
11
|
+
"TaskEvent",
|
|
12
|
+
"TaskGraph",
|
|
13
|
+
"TaskNode",
|
|
14
|
+
"TaskState",
|
|
15
|
+
]
|
stemtrace/core/events.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Task event definitions."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from pydantic import BaseModel, ConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TaskState(str, Enum):
|
|
11
|
+
"""Celery task states. Inherits from str for easy comparison."""
|
|
12
|
+
|
|
13
|
+
PENDING = "PENDING"
|
|
14
|
+
RECEIVED = "RECEIVED"
|
|
15
|
+
STARTED = "STARTED"
|
|
16
|
+
SUCCESS = "SUCCESS"
|
|
17
|
+
FAILURE = "FAILURE"
|
|
18
|
+
REVOKED = "REVOKED"
|
|
19
|
+
REJECTED = "REJECTED"
|
|
20
|
+
RETRY = "RETRY"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TaskEvent(BaseModel):
|
|
24
|
+
"""Immutable task lifecycle event.
|
|
25
|
+
|
|
26
|
+
Frozen model that can be hashed and compared. Captures a single
|
|
27
|
+
state transition in a Celery task's lifecycle.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
task_id: Unique identifier for the task execution.
|
|
31
|
+
name: Fully qualified task name (e.g., 'myapp.tasks.add').
|
|
32
|
+
state: Current state of the task.
|
|
33
|
+
timestamp: When this event occurred.
|
|
34
|
+
parent_id: ID of the parent task that spawned this one.
|
|
35
|
+
root_id: ID of the root task in the workflow.
|
|
36
|
+
group_id: ID shared by tasks in the same group/chord.
|
|
37
|
+
chord_id: ID of the group for which this header task's chord will complete.
|
|
38
|
+
chord_callback_id: Task ID of the chord callback (only set on header tasks).
|
|
39
|
+
trace_id: Optional distributed tracing ID.
|
|
40
|
+
retries: Number of retry attempts so far.
|
|
41
|
+
args: Positional arguments passed to the task (scrubbed).
|
|
42
|
+
kwargs: Keyword arguments passed to the task (scrubbed).
|
|
43
|
+
result: Return value of the task (SUCCESS state only).
|
|
44
|
+
exception: Exception message (FAILURE/RETRY states).
|
|
45
|
+
traceback: Full traceback string (FAILURE/RETRY states).
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model_config = ConfigDict(frozen=True)
|
|
49
|
+
|
|
50
|
+
task_id: str
|
|
51
|
+
name: str
|
|
52
|
+
state: TaskState
|
|
53
|
+
timestamp: datetime
|
|
54
|
+
parent_id: str | None = None
|
|
55
|
+
root_id: str | None = None
|
|
56
|
+
group_id: str | None = None
|
|
57
|
+
chord_id: str | None = None
|
|
58
|
+
chord_callback_id: str | None = None
|
|
59
|
+
trace_id: str | None = None
|
|
60
|
+
retries: int = 0
|
|
61
|
+
|
|
62
|
+
# New fields for enhanced event data
|
|
63
|
+
args: list[Any] | None = None
|
|
64
|
+
kwargs: dict[str, Any] | None = None
|
|
65
|
+
result: Any | None = None
|
|
66
|
+
exception: str | None = None
|
|
67
|
+
traceback: str | None = None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Domain exceptions for stemtrace."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class StemtraceError(Exception):
|
|
5
|
+
"""Base exception for stemtrace."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(StemtraceError):
|
|
9
|
+
"""Invalid or missing configuration."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class TransportError(StemtraceError):
|
|
13
|
+
"""Transport connection, publish, or consume failure."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class UnsupportedBrokerError(ConfigurationError):
|
|
17
|
+
"""Broker URL scheme not supported."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, scheme: str) -> None:
|
|
20
|
+
"""Initialize with the unsupported broker scheme."""
|
|
21
|
+
self.scheme = scheme
|
|
22
|
+
super().__init__(
|
|
23
|
+
f"Unsupported broker scheme: '{scheme}'. "
|
|
24
|
+
f"Supported: redis, rediss, amqp, amqps, memory"
|
|
25
|
+
)
|
stemtrace/core/graph.py
ADDED
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
"""Task graph models for representing task execution flows."""
|
|
2
|
+
|
|
3
|
+
from enum import Enum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
6
|
+
|
|
7
|
+
from stemtrace.core.events import TaskEvent, TaskState
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class NodeType(str, Enum):
|
|
11
|
+
"""Type of node in the task graph."""
|
|
12
|
+
|
|
13
|
+
TASK = "TASK"
|
|
14
|
+
GROUP = "GROUP"
|
|
15
|
+
CHORD = "CHORD"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class TaskNode(BaseModel):
|
|
19
|
+
"""Mutable node in the task graph. Tracks event history and child relationships."""
|
|
20
|
+
|
|
21
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
22
|
+
|
|
23
|
+
task_id: str
|
|
24
|
+
name: str
|
|
25
|
+
state: TaskState
|
|
26
|
+
node_type: NodeType = NodeType.TASK
|
|
27
|
+
group_id: str | None = None
|
|
28
|
+
chord_id: str | None = None
|
|
29
|
+
chord_callback_id: str | None = None
|
|
30
|
+
events: list[TaskEvent] = Field(default_factory=list)
|
|
31
|
+
children: list[str] = Field(default_factory=list)
|
|
32
|
+
parent_id: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TaskGraph(BaseModel):
|
|
36
|
+
"""DAG of task executions, built incrementally from events.
|
|
37
|
+
|
|
38
|
+
Parent-child linking only occurs when parent exists at child insertion time.
|
|
39
|
+
Out-of-order events won't back-link.
|
|
40
|
+
|
|
41
|
+
Synthetic nodes (GROUP, CHORD) are created when tasks share a group_id.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
model_config = ConfigDict(validate_assignment=True)
|
|
45
|
+
|
|
46
|
+
nodes: dict[str, TaskNode] = Field(default_factory=dict)
|
|
47
|
+
root_ids: list[str] = Field(default_factory=list)
|
|
48
|
+
|
|
49
|
+
# Track which tasks belong to each group_id (private, not serialized)
|
|
50
|
+
_group_members: dict[str, list[str]] = PrivateAttr(default_factory=dict)
|
|
51
|
+
|
|
52
|
+
def add_event(self, event: TaskEvent) -> None:
|
|
53
|
+
"""Add event, creating node if needed. Links child to parent if parent exists.
|
|
54
|
+
|
|
55
|
+
Also tracks group membership and creates synthetic GROUP/CHORD nodes.
|
|
56
|
+
"""
|
|
57
|
+
if event.task_id not in self.nodes:
|
|
58
|
+
self.nodes[event.task_id] = TaskNode(
|
|
59
|
+
task_id=event.task_id,
|
|
60
|
+
name=event.name,
|
|
61
|
+
state=event.state,
|
|
62
|
+
parent_id=event.parent_id,
|
|
63
|
+
group_id=event.group_id,
|
|
64
|
+
chord_id=event.chord_id,
|
|
65
|
+
)
|
|
66
|
+
if event.parent_id is None:
|
|
67
|
+
self.root_ids.append(event.task_id)
|
|
68
|
+
elif event.parent_id in self.nodes:
|
|
69
|
+
self.nodes[event.parent_id].children.append(event.task_id)
|
|
70
|
+
|
|
71
|
+
node = self.nodes[event.task_id]
|
|
72
|
+
node.events.append(event)
|
|
73
|
+
node.state = event.state
|
|
74
|
+
|
|
75
|
+
# Update group_id if we didn't have it before
|
|
76
|
+
if node.group_id is None and event.group_id is not None:
|
|
77
|
+
node.group_id = event.group_id
|
|
78
|
+
|
|
79
|
+
# Update chord_id if we didn't have it before
|
|
80
|
+
if node.chord_id is None and event.chord_id is not None:
|
|
81
|
+
node.chord_id = event.chord_id
|
|
82
|
+
|
|
83
|
+
# Update parent_id if we didn't have it before (PENDING lacks parent_id)
|
|
84
|
+
if node.parent_id is None and event.parent_id is not None:
|
|
85
|
+
node.parent_id = event.parent_id
|
|
86
|
+
if event.task_id in self.root_ids:
|
|
87
|
+
self.root_ids.remove(event.task_id)
|
|
88
|
+
if event.parent_id in self.nodes:
|
|
89
|
+
parent = self.nodes[event.parent_id]
|
|
90
|
+
if event.task_id not in parent.children:
|
|
91
|
+
parent.children.append(event.task_id)
|
|
92
|
+
|
|
93
|
+
# Track group membership for synthetic node creation
|
|
94
|
+
if event.group_id is not None:
|
|
95
|
+
self._track_group_member(event.task_id, event.group_id)
|
|
96
|
+
|
|
97
|
+
# Track chord - when header task has chord_callback_id, upgrade GROUP to CHORD
|
|
98
|
+
if event.chord_callback_id is not None and event.group_id is not None:
|
|
99
|
+
# This is a HEADER task with chord info - upgrade GROUP to CHORD
|
|
100
|
+
self._upgrade_to_chord(event.group_id, event.chord_callback_id)
|
|
101
|
+
|
|
102
|
+
# Check if this task is a callback for any existing CHORD
|
|
103
|
+
self._link_chord_callback_if_needed(event.task_id)
|
|
104
|
+
|
|
105
|
+
def _track_group_member(self, task_id: str, group_id: str) -> None:
|
|
106
|
+
"""Track task as member of a group and create synthetic node if needed.
|
|
107
|
+
|
|
108
|
+
Creates a synthetic GROUP node when 2+ tasks share a group_id.
|
|
109
|
+
If tasks share a common parent, the GROUP becomes a child of that parent.
|
|
110
|
+
Skips adding callback tasks (they're outside the group container).
|
|
111
|
+
"""
|
|
112
|
+
# Check if this task is the callback for a CHORD - link but don't add to members
|
|
113
|
+
group_node_id = f"group:{group_id}"
|
|
114
|
+
if group_node_id in self.nodes:
|
|
115
|
+
chord_node = self.nodes[group_node_id]
|
|
116
|
+
if (
|
|
117
|
+
chord_node.node_type == NodeType.CHORD
|
|
118
|
+
and chord_node.chord_id == task_id
|
|
119
|
+
):
|
|
120
|
+
# This is the callback - link it to CHORD but not as group member
|
|
121
|
+
task_node = self.nodes[task_id]
|
|
122
|
+
if task_node.parent_id is None:
|
|
123
|
+
task_node.parent_id = group_node_id
|
|
124
|
+
if task_id in self.root_ids:
|
|
125
|
+
self.root_ids.remove(task_id)
|
|
126
|
+
if task_id not in chord_node.children:
|
|
127
|
+
chord_node.children.append(task_id)
|
|
128
|
+
return # Don't add to _group_members
|
|
129
|
+
|
|
130
|
+
if group_id not in self._group_members:
|
|
131
|
+
self._group_members[group_id] = []
|
|
132
|
+
|
|
133
|
+
if task_id not in self._group_members[group_id]:
|
|
134
|
+
self._group_members[group_id].append(task_id)
|
|
135
|
+
|
|
136
|
+
# Only create GROUP node for standalone groups (no parent task)
|
|
137
|
+
group_node_id = f"group:{group_id}"
|
|
138
|
+
members = self._group_members[group_id]
|
|
139
|
+
|
|
140
|
+
should_create = (
|
|
141
|
+
len(members) >= 2
|
|
142
|
+
and group_node_id not in self.nodes
|
|
143
|
+
and self._should_create_group_node(members)
|
|
144
|
+
)
|
|
145
|
+
if should_create:
|
|
146
|
+
self._create_group_node(group_id)
|
|
147
|
+
|
|
148
|
+
# Update group node if it exists
|
|
149
|
+
if group_node_id in self.nodes:
|
|
150
|
+
group_node = self.nodes[group_node_id]
|
|
151
|
+
if task_id not in group_node.children:
|
|
152
|
+
group_node.children.append(task_id)
|
|
153
|
+
|
|
154
|
+
group_node.state = self._compute_group_state(members)
|
|
155
|
+
|
|
156
|
+
# Update task's parent to point to group node (if no other parent)
|
|
157
|
+
task_node = self.nodes[task_id]
|
|
158
|
+
if task_node.parent_id is None:
|
|
159
|
+
task_node.parent_id = group_node_id
|
|
160
|
+
if task_id in self.root_ids:
|
|
161
|
+
self.root_ids.remove(task_id)
|
|
162
|
+
|
|
163
|
+
def _should_create_group_node(self, member_ids: list[str]) -> bool:
|
|
164
|
+
"""Determine if a synthetic GROUP node should be created.
|
|
165
|
+
|
|
166
|
+
Always returns True when there are 2+ members - we want to visualize
|
|
167
|
+
groups even when they have a common parent, to differentiate from
|
|
168
|
+
independent subtasks.
|
|
169
|
+
"""
|
|
170
|
+
return len(member_ids) >= 2
|
|
171
|
+
|
|
172
|
+
def _get_common_parent(self, member_ids: list[str]) -> str | None:
|
|
173
|
+
"""Get common parent_id if all members share the same parent.
|
|
174
|
+
|
|
175
|
+
Returns the parent_id if all members have the same parent,
|
|
176
|
+
or None if they have different parents or are standalone.
|
|
177
|
+
"""
|
|
178
|
+
parents: set[str | None] = set()
|
|
179
|
+
for mid in member_ids:
|
|
180
|
+
if mid in self.nodes:
|
|
181
|
+
parents.add(self.nodes[mid].parent_id)
|
|
182
|
+
|
|
183
|
+
# Return the common parent if there's exactly one non-None parent
|
|
184
|
+
if len(parents) == 1 and None not in parents:
|
|
185
|
+
return parents.pop()
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
def _create_group_node(self, group_id: str) -> None:
|
|
189
|
+
"""Create a synthetic GROUP node for tasks sharing a group_id.
|
|
190
|
+
|
|
191
|
+
If all members share a common parent task, the GROUP becomes a child
|
|
192
|
+
of that parent. Otherwise, the GROUP is a root node.
|
|
193
|
+
"""
|
|
194
|
+
group_node_id = f"group:{group_id}"
|
|
195
|
+
members = self._group_members.get(group_id, [])
|
|
196
|
+
|
|
197
|
+
# Check if members share a common parent
|
|
198
|
+
common_parent = self._get_common_parent(members)
|
|
199
|
+
|
|
200
|
+
# Determine the group's state based on member states
|
|
201
|
+
group_state = self._compute_group_state(members)
|
|
202
|
+
|
|
203
|
+
self.nodes[group_node_id] = TaskNode(
|
|
204
|
+
task_id=group_node_id,
|
|
205
|
+
name="group",
|
|
206
|
+
state=group_state,
|
|
207
|
+
node_type=NodeType.GROUP,
|
|
208
|
+
group_id=group_id,
|
|
209
|
+
children=list(members),
|
|
210
|
+
parent_id=common_parent,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
if common_parent is not None:
|
|
214
|
+
# GROUP is child of common parent
|
|
215
|
+
parent_node = self.nodes.get(common_parent)
|
|
216
|
+
if parent_node and group_node_id not in parent_node.children:
|
|
217
|
+
parent_node.children.append(group_node_id)
|
|
218
|
+
# Remove member tasks from parent's children (they're now in GROUP)
|
|
219
|
+
if parent_node:
|
|
220
|
+
for member_id in members:
|
|
221
|
+
if member_id in parent_node.children:
|
|
222
|
+
parent_node.children.remove(member_id)
|
|
223
|
+
else:
|
|
224
|
+
# GROUP is a root node
|
|
225
|
+
if group_node_id not in self.root_ids:
|
|
226
|
+
self.root_ids.append(group_node_id)
|
|
227
|
+
|
|
228
|
+
# Update member nodes to point to group as parent
|
|
229
|
+
for member_id in members:
|
|
230
|
+
if member_id in self.nodes:
|
|
231
|
+
member = self.nodes[member_id]
|
|
232
|
+
# Store original parent before updating (for later members)
|
|
233
|
+
member.parent_id = group_node_id
|
|
234
|
+
# Remove from root_ids since it now has a parent
|
|
235
|
+
if member_id in self.root_ids:
|
|
236
|
+
self.root_ids.remove(member_id)
|
|
237
|
+
|
|
238
|
+
def _compute_group_state(self, member_ids: list[str]) -> TaskState:
|
|
239
|
+
"""Compute aggregate state for a group based on member states.
|
|
240
|
+
|
|
241
|
+
Priority: FAILURE > STARTED/RECEIVED > PENDING > RETRY > REVOKED > SUCCESS
|
|
242
|
+
"""
|
|
243
|
+
states = [self.nodes[mid].state for mid in member_ids if mid in self.nodes]
|
|
244
|
+
if not states:
|
|
245
|
+
return TaskState.PENDING
|
|
246
|
+
|
|
247
|
+
state_set = set(states)
|
|
248
|
+
|
|
249
|
+
if TaskState.FAILURE in state_set:
|
|
250
|
+
return TaskState.FAILURE
|
|
251
|
+
if state_set & {TaskState.STARTED, TaskState.RECEIVED}:
|
|
252
|
+
return TaskState.STARTED
|
|
253
|
+
if TaskState.PENDING in state_set:
|
|
254
|
+
return TaskState.PENDING
|
|
255
|
+
if TaskState.RETRY in state_set:
|
|
256
|
+
return TaskState.RETRY
|
|
257
|
+
if TaskState.REVOKED in state_set:
|
|
258
|
+
return TaskState.REVOKED
|
|
259
|
+
if all(s == TaskState.SUCCESS for s in states):
|
|
260
|
+
return TaskState.SUCCESS
|
|
261
|
+
|
|
262
|
+
return states[0]
|
|
263
|
+
|
|
264
|
+
def _upgrade_to_chord(self, group_id: str, callback_id: str) -> None:
|
|
265
|
+
"""Upgrade a GROUP node to CHORD and register the callback.
|
|
266
|
+
|
|
267
|
+
Called when a header task has chord info (chord_callback_id set).
|
|
268
|
+
The callback will be linked outside the container.
|
|
269
|
+
"""
|
|
270
|
+
group_node_id = f"group:{group_id}"
|
|
271
|
+
members = self._group_members.get(group_id, [])
|
|
272
|
+
|
|
273
|
+
# If no GROUP node exists yet, create it as CHORD
|
|
274
|
+
if group_node_id not in self.nodes:
|
|
275
|
+
self.nodes[group_node_id] = TaskNode(
|
|
276
|
+
task_id=group_node_id,
|
|
277
|
+
name="chord",
|
|
278
|
+
state=TaskState.PENDING,
|
|
279
|
+
node_type=NodeType.CHORD,
|
|
280
|
+
group_id=group_id,
|
|
281
|
+
chord_id=callback_id, # Store callback reference
|
|
282
|
+
chord_callback_id=callback_id,
|
|
283
|
+
children=list(members), # Add existing members as children
|
|
284
|
+
)
|
|
285
|
+
if group_node_id not in self.root_ids:
|
|
286
|
+
self.root_ids.append(group_node_id)
|
|
287
|
+
# Update member nodes to point to CHORD as parent
|
|
288
|
+
for member_id in members:
|
|
289
|
+
if member_id in self.nodes:
|
|
290
|
+
member = self.nodes[member_id]
|
|
291
|
+
member.parent_id = group_node_id
|
|
292
|
+
if member_id in self.root_ids:
|
|
293
|
+
self.root_ids.remove(member_id)
|
|
294
|
+
else:
|
|
295
|
+
# Upgrade existing GROUP to CHORD
|
|
296
|
+
group_node = self.nodes[group_node_id]
|
|
297
|
+
if group_node.node_type == NodeType.GROUP:
|
|
298
|
+
group_node.node_type = NodeType.CHORD
|
|
299
|
+
group_node.name = "chord"
|
|
300
|
+
group_node.chord_id = callback_id # Store callback reference
|
|
301
|
+
group_node.chord_callback_id = callback_id
|
|
302
|
+
|
|
303
|
+
# Link callback to CHORD if it already exists
|
|
304
|
+
group_node = self.nodes[group_node_id]
|
|
305
|
+
callback_node = self.nodes.get(callback_id)
|
|
306
|
+
if callback_node:
|
|
307
|
+
# Callback should be OUTSIDE container, linked as child for edge
|
|
308
|
+
if callback_node.parent_id is None:
|
|
309
|
+
callback_node.parent_id = group_node_id
|
|
310
|
+
if callback_id in self.root_ids:
|
|
311
|
+
self.root_ids.remove(callback_id)
|
|
312
|
+
# Ensure callback is in children (for edge rendering)
|
|
313
|
+
if callback_id not in group_node.children:
|
|
314
|
+
group_node.children.append(callback_id)
|
|
315
|
+
# Remove callback from group members (it's not a header task)
|
|
316
|
+
if (
|
|
317
|
+
group_id in self._group_members
|
|
318
|
+
and callback_id in self._group_members[group_id]
|
|
319
|
+
):
|
|
320
|
+
self._group_members[group_id].remove(callback_id)
|
|
321
|
+
|
|
322
|
+
def _link_chord_callback_if_needed(self, task_id: str) -> None:
|
|
323
|
+
"""Link a task to a CHORD node if it's the callback for that chord.
|
|
324
|
+
|
|
325
|
+
Called when a new task arrives. Checks if any existing CHORD node
|
|
326
|
+
is waiting for this task as its callback.
|
|
327
|
+
"""
|
|
328
|
+
for node in self.nodes.values():
|
|
329
|
+
if node.node_type == NodeType.CHORD and node.chord_callback_id == task_id:
|
|
330
|
+
callback_node = self.nodes.get(task_id)
|
|
331
|
+
if callback_node:
|
|
332
|
+
# Link callback to CHORD
|
|
333
|
+
if callback_node.parent_id is None:
|
|
334
|
+
callback_node.parent_id = node.task_id
|
|
335
|
+
if task_id in self.root_ids:
|
|
336
|
+
self.root_ids.remove(task_id)
|
|
337
|
+
# Add to children for edge rendering
|
|
338
|
+
if task_id not in node.children:
|
|
339
|
+
node.children.append(task_id)
|
|
340
|
+
return # Only one CHORD can have this callback
|
|
341
|
+
|
|
342
|
+
def get_node(self, task_id: str) -> TaskNode | None:
|
|
343
|
+
"""Get node by ID, or None if not found."""
|
|
344
|
+
return self.nodes.get(task_id)
|
|
345
|
+
|
|
346
|
+
def get_group_members(self, group_id: str) -> list[str]:
|
|
347
|
+
"""Get task IDs that belong to a group."""
|
|
348
|
+
return self._group_members.get(group_id, [])
|
stemtrace/core/ports.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Protocol definitions for dependency inversion."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Iterator
|
|
4
|
+
from typing import TYPE_CHECKING, Protocol
|
|
5
|
+
|
|
6
|
+
from typing_extensions import Self
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from stemtrace.core.events import TaskEvent
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class EventTransport(Protocol):
|
|
13
|
+
"""Broker-agnostic transport. publish() must be fire-and-forget (never raise)."""
|
|
14
|
+
|
|
15
|
+
def publish(self, event: "TaskEvent") -> None:
|
|
16
|
+
"""Publish event. Fire-and-forget: log errors, don't raise."""
|
|
17
|
+
...
|
|
18
|
+
|
|
19
|
+
def consume(self) -> Iterator["TaskEvent"]:
|
|
20
|
+
"""Yield events as they arrive. May block waiting for new events."""
|
|
21
|
+
...
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_url(cls, url: str) -> Self:
|
|
25
|
+
"""Create transport from broker URL. Raises UnsupportedBrokerError if unknown."""
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class TaskRepository(Protocol):
|
|
30
|
+
"""Read-only task data access for API endpoints."""
|
|
31
|
+
|
|
32
|
+
def get(self, task_id: str) -> "TaskEvent | None":
|
|
33
|
+
"""Get latest event for a task."""
|
|
34
|
+
...
|
|
35
|
+
|
|
36
|
+
def list_recent(self, limit: int = 100) -> list["TaskEvent"]:
|
|
37
|
+
"""List recent events, most recent first."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def get_children(self, parent_id: str) -> list["TaskEvent"]:
|
|
41
|
+
"""Get events for tasks spawned by parent."""
|
|
42
|
+
...
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Library component - Celery integration."""
|