Skip to content

Commit 36a230a

Browse files
committed
fix(tracing): detach orphan spans before database flush (#14243)
fix(tracing): detach spans from missing parents
1 parent c4276ea commit 36a230a

2 files changed

Lines changed: 62 additions & 6 deletions

File tree

src/backend/base/langflow/services/tracing/span_sorting.py

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,22 +63,37 @@ def topological_sort_spans(
6363
sort over the batch so that every parent is inserted first.
6464
6565
Spans whose ``parent_span_id`` points outside the current batch are
66-
treated as roots (the parent already exists in the DB from a prior
67-
flush).
66+
detached from that missing parent. Native tracing persists a trace's
67+
spans in a single batch, so retaining the reference would violate the
68+
immediate foreign-key constraint.
6869
"""
6970
batch_ids = {span_uuid for _, span_uuid, _ in resolved}
7071

72+
normalized: list[tuple[dict[str, Any], UUID, UUID | None]] = []
73+
missing_parent_count = 0
74+
for span_data, span_uuid, parent_uuid in resolved:
75+
normalized_parent_uuid = parent_uuid
76+
if normalized_parent_uuid is not None and normalized_parent_uuid not in batch_ids:
77+
normalized_parent_uuid = None
78+
missing_parent_count += 1
79+
normalized.append((span_data, span_uuid, normalized_parent_uuid))
80+
81+
if missing_parent_count:
82+
logger.warning(
83+
"Detached %d tracing spans from missing parents to preserve database integrity.",
84+
missing_parent_count,
85+
)
86+
7187
sorted_spans: list[tuple[dict[str, Any], UUID, UUID | None]] = []
7288
inserted: set[UUID] = set()
73-
remaining = list(resolved)
89+
remaining = normalized
7490

7591
while remaining:
7692
next_round: list[tuple[dict[str, Any], UUID, UUID | None]] = []
7793
progress = False
7894
for item in remaining:
7995
_, span_uuid, parent_uuid = item
80-
# Insert if: no parent, parent outside batch, or parent already inserted
81-
if parent_uuid is None or parent_uuid not in batch_ids or parent_uuid in inserted:
96+
if parent_uuid is None or parent_uuid in inserted:
8297
sorted_spans.append(item)
8398
inserted.add(span_uuid)
8499
progress = True

src/backend/tests/unit/services/tracing/test_native_tracer.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,13 +691,14 @@ def test_deep_nesting(self):
691691
assert uuids.index(root) < uuids.index(mid) < uuids.index(leaf)
692692

693693
def test_parent_outside_batch(self):
694-
"""Spans referencing a parent not in the batch are treated as roots."""
694+
"""Spans referencing a missing parent are detached before insertion."""
695695
external_parent = uuid4()
696696
child_id = uuid4()
697697
items = [self._make_span(child_id, external_parent)]
698698
result = topological_sort_spans(items)
699699
assert len(result) == 1
700700
assert result[0][1] == child_id
701+
assert result[0][2] is None
701702

702703
def test_mixed_roots_and_children(self):
703704
root_a = uuid4()
@@ -891,3 +892,43 @@ async def capture_merge(obj):
891892
assert span_objects[0].id == parent_uuid
892893
assert span_objects[1].id == child_uuid
893894
assert span_objects[1].parent_span_id == parent_uuid
895+
896+
async def test_flush_detaches_span_from_missing_parent(self):
897+
"""A missing parent must not leave an invalid self-referential FK."""
898+
tracer = _make_tracer(flow_id=str(uuid4()))
899+
tracer.completed_spans = [
900+
{
901+
"id": str(uuid4()),
902+
"name": "Orphan Span",
903+
"span_type": SpanType.CHAIN,
904+
"inputs": {},
905+
"outputs": None,
906+
"start_time": datetime.now(tz=timezone.utc),
907+
"end_time": datetime.now(tz=timezone.utc),
908+
"latency_ms": 5,
909+
"status": SpanStatus.OK,
910+
"error": None,
911+
"attributes": {},
912+
"span_source": "langchain",
913+
"parent_span_id": uuid4(),
914+
}
915+
]
916+
917+
merged_objects = []
918+
mock_session = AsyncMock()
919+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
920+
mock_session.__aexit__ = AsyncMock(return_value=False)
921+
922+
async def capture_merge(obj):
923+
merged_objects.append(obj)
924+
925+
mock_session.merge = capture_merge
926+
927+
with patch("lfx.services.deps.session_scope", return_value=mock_session):
928+
await tracer._flush_to_database()
929+
930+
from langflow.services.database.models.traces.model import SpanTable
931+
932+
span_objects = [obj for obj in merged_objects if isinstance(obj, SpanTable)]
933+
assert len(span_objects) == 1
934+
assert span_objects[0].parent_span_id is None

0 commit comments

Comments
 (0)