Skip to content

Commit a35d5eb

Browse files
committed
fix: Topological sort on child spans
1 parent 0a6f7e0 commit a35d5eb

2 files changed

Lines changed: 260 additions & 18 deletions

File tree

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

Lines changed: 92 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -320,25 +320,13 @@ async def _flush_to_database(self, error: Exception | None = None) -> None:
320320
)
321321
await session.merge(trace)
322322

323-
for span_data in self.completed_spans:
324-
try:
325-
span_uuid = UUID_(span_data["id"])
326-
except (ValueError, TypeError):
327-
# Span IDs from LangChain callbacks are strings, not UUIDs — derive
328-
# a stable UUID so the same span always maps to the same DB row.
329-
span_uuid = uuid5(LANGFLOW_SPAN_NAMESPACE, f"{self.trace_id}-{span_data['id']}")
330-
331-
parent_uuid = None
332-
if span_data.get("parent_span_id"):
333-
parent_id = span_data["parent_span_id"]
334-
if isinstance(parent_id, UUID_):
335-
parent_uuid = parent_id
336-
else:
337-
try:
338-
parent_uuid = UUID_(str(parent_id))
339-
except (ValueError, TypeError):
340-
parent_uuid = uuid5(LANGFLOW_SPAN_NAMESPACE, f"{self.trace_id}-{parent_id}")
323+
# Pre-compute UUIDs and topologically sort so parents are inserted
324+
# before children — required by PostgreSQL's immediate FK enforcement
325+
# on span.parent_span_id → span.id.
326+
resolved = self._resolve_span_uuids(self.completed_spans)
327+
resolved = self._topological_sort_spans(resolved)
341328

329+
for span_data, span_uuid, parent_uuid in resolved:
342330
span = SpanTable(
343331
id=span_uuid,
344332
trace_id=self.trace_id,
@@ -553,6 +541,92 @@ def _build_completed_span(
553541
span["parent_span_id"] = parent_span_id
554542
return span
555543

544+
def _resolve_span_uuids(
545+
self,
546+
completed_spans: list[dict[str, Any]],
547+
) -> list[tuple[dict[str, Any], UUID, UUID | None]]:
548+
"""Pre-compute DB UUIDs for each span and its parent.
549+
550+
Returns a list of (span_data, span_uuid, parent_uuid) tuples that can
551+
be topologically sorted before insertion.
552+
"""
553+
from uuid import UUID as UUID_
554+
555+
resolved: list[tuple[dict[str, Any], UUID, UUID | None]] = []
556+
for span_data in completed_spans:
557+
try:
558+
span_uuid = UUID_(span_data["id"])
559+
except (ValueError, TypeError):
560+
span_uuid = uuid5(LANGFLOW_SPAN_NAMESPACE, f"{self.trace_id}-{span_data['id']}")
561+
562+
parent_uuid: UUID | None = None
563+
if span_data.get("parent_span_id"):
564+
parent_id = span_data["parent_span_id"]
565+
if isinstance(parent_id, UUID_):
566+
parent_uuid = parent_id
567+
else:
568+
try:
569+
parent_uuid = UUID_(str(parent_id))
570+
except (ValueError, TypeError):
571+
parent_uuid = uuid5(LANGFLOW_SPAN_NAMESPACE, f"{self.trace_id}-{parent_id}")
572+
573+
resolved.append((span_data, span_uuid, parent_uuid))
574+
return resolved
575+
576+
@staticmethod
577+
def _topological_sort_spans(
578+
resolved: list[tuple[dict[str, Any], UUID, UUID | None]],
579+
) -> list[tuple[dict[str, Any], UUID, UUID | None]]:
580+
"""Sort spans so parents appear before children.
581+
582+
PostgreSQL enforces foreign-key constraints at INSERT time, so a child
583+
span referencing ``parent_span_id`` will fail if the parent row hasn't
584+
been written yet. This performs a Kahn's-algorithm-style topological
585+
sort over the batch so that every parent is inserted first.
586+
587+
Spans whose ``parent_span_id`` points outside the current batch are
588+
treated as roots (the parent already exists in the DB from a prior
589+
flush).
590+
"""
591+
batch_ids = {span_uuid for _, span_uuid, _ in resolved}
592+
593+
sorted_spans: list[tuple[dict[str, Any], UUID, UUID | None]] = []
594+
inserted: set[UUID] = set()
595+
remaining = list(resolved)
596+
597+
while remaining:
598+
next_round: list[tuple[dict[str, Any], UUID, UUID | None]] = []
599+
progress = False
600+
for item in remaining:
601+
_, span_uuid, parent_uuid = item
602+
# Insert if: no parent, parent outside batch, or parent already inserted
603+
if parent_uuid is None or parent_uuid not in batch_ids or parent_uuid in inserted:
604+
sorted_spans.append(item)
605+
inserted.add(span_uuid)
606+
progress = True
607+
else:
608+
next_round.append(item)
609+
610+
if not progress:
611+
# Cycle or unresolvable dependency detected.
612+
# To avoid reintroducing foreign-key violations, break the cycle by
613+
# nulling out parent_span_id for the remaining spans before inserting.
614+
if next_round:
615+
logger.warning(
616+
"Detected cycle or unresolvable span dependencies in tracing batch; "
617+
"breaking parent relationships for %d spans to preserve DB integrity.",
618+
len(next_round),
619+
)
620+
for span_data, span_uuid, _parent_uuid in next_round:
621+
# Ensure the payload reflects the broken parent relationship.
622+
if isinstance(span_data, dict):
623+
span_data["parent_span_id"] = None
624+
sorted_spans.append((span_data, span_uuid, None))
625+
break
626+
remaining = next_round
627+
628+
return sorted_spans
629+
556630
@staticmethod
557631
def _map_trace_type(trace_type: str) -> SpanType:
558632
"""Normalise Langflow's string trace types to the SpanType enum, defaulting to CHAIN for unknown values."""

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

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -604,3 +604,171 @@ def test_callback_has_no_parent_span_id_when_no_component(self):
604604
assert callback is not None
605605
assert isinstance(callback, NativeCallbackHandler)
606606
assert callback.parent_span_id is None
607+
608+
609+
# ---------------------------------------------------------------------------
610+
# _topological_sort_spans
611+
# ---------------------------------------------------------------------------
612+
613+
614+
class TestTopologicalSortSpans:
615+
"""Verify that spans are sorted so parents appear before children.
616+
617+
This is critical for PostgreSQL which enforces FK constraints at INSERT time.
618+
"""
619+
620+
@staticmethod
621+
def _make_span(span_id, parent_id=None) -> tuple[dict, UUID, UUID | None]:
622+
"""Helper to create a resolved (span_data, span_uuid, parent_uuid) tuple."""
623+
span_data = {"id": str(span_id), "name": f"span-{span_id}"}
624+
return (span_data, span_id, parent_id)
625+
626+
def test_no_parents(self):
627+
a, b, c = uuid4(), uuid4(), uuid4()
628+
items = [self._make_span(a), self._make_span(b), self._make_span(c)]
629+
result = NativeTracer._topological_sort_spans(items)
630+
assert [r[1] for r in result] == [a, b, c]
631+
632+
def test_child_after_parent(self):
633+
parent_id = uuid4()
634+
child_id = uuid4()
635+
# Child comes first in the input list
636+
items = [self._make_span(child_id, parent_id), self._make_span(parent_id)]
637+
result = NativeTracer._topological_sort_spans(items)
638+
uuids = [r[1] for r in result]
639+
assert uuids.index(parent_id) < uuids.index(child_id)
640+
641+
def test_deep_nesting(self):
642+
root = uuid4()
643+
mid = uuid4()
644+
leaf = uuid4()
645+
# Reverse order: leaf, mid, root
646+
items = [
647+
self._make_span(leaf, mid),
648+
self._make_span(mid, root),
649+
self._make_span(root),
650+
]
651+
result = NativeTracer._topological_sort_spans(items)
652+
uuids = [r[1] for r in result]
653+
assert uuids.index(root) < uuids.index(mid) < uuids.index(leaf)
654+
655+
def test_parent_outside_batch(self):
656+
"""Spans referencing a parent not in the batch are treated as roots."""
657+
external_parent = uuid4()
658+
child_id = uuid4()
659+
items = [self._make_span(child_id, external_parent)]
660+
result = NativeTracer._topological_sort_spans(items)
661+
assert len(result) == 1
662+
assert result[0][1] == child_id
663+
664+
def test_mixed_roots_and_children(self):
665+
root_a = uuid4()
666+
child_a = uuid4()
667+
root_b = uuid4()
668+
items = [
669+
self._make_span(child_a, root_a),
670+
self._make_span(root_b),
671+
self._make_span(root_a),
672+
]
673+
result = NativeTracer._topological_sort_spans(items)
674+
uuids = [r[1] for r in result]
675+
assert uuids.index(root_a) < uuids.index(child_a)
676+
677+
def test_cycle_two_node(self):
678+
"""Spans forming a 2-node cycle should not cause errors or drop spans."""
679+
a = uuid4()
680+
b = uuid4()
681+
items = [
682+
self._make_span(a, b),
683+
self._make_span(b, a),
684+
]
685+
result = NativeTracer._topological_sort_spans(items)
686+
# Ensure all spans are present and no infinite loop / exception occurs.
687+
uuids = [r[1] for r in result]
688+
assert len(uuids) == 2
689+
assert set(uuids) == {a, b}
690+
691+
def test_self_parent_span(self):
692+
"""A span that lists itself as its own parent should still be returned."""
693+
span_id = uuid4()
694+
items = [
695+
self._make_span(span_id, span_id),
696+
]
697+
result = NativeTracer._topological_sort_spans(items)
698+
uuids = [r[1] for r in result]
699+
assert len(uuids) == 1
700+
assert uuids[0] == span_id
701+
702+
def test_empty_input(self):
703+
result = NativeTracer._topological_sort_spans([])
704+
assert result == []
705+
706+
# ---------------------------------------------------------------------------
707+
# _flush_to_database with parent/child spans
708+
# ---------------------------------------------------------------------------
709+
710+
711+
class TestFlushParentChildOrder:
712+
@pytest.mark.asyncio
713+
async def test_flush_inserts_parent_before_child(self):
714+
"""Verify that the DB merge order respects parent -> child ordering."""
715+
flow_id = str(uuid4())
716+
tracer = _make_tracer(flow_id=flow_id)
717+
718+
parent_uuid = uuid4()
719+
child_uuid = uuid4()
720+
721+
# Deliberately add child first to simulate the problematic ordering
722+
tracer.completed_spans = [
723+
{
724+
"id": str(child_uuid),
725+
"name": "Child Span",
726+
"span_type": SpanType.CHAIN,
727+
"inputs": {},
728+
"outputs": None,
729+
"start_time": datetime.now(tz=timezone.utc),
730+
"end_time": datetime.now(tz=timezone.utc),
731+
"latency_ms": 5,
732+
"status": SpanStatus.OK,
733+
"error": None,
734+
"attributes": {},
735+
"span_source": "component",
736+
"parent_span_id": parent_uuid,
737+
},
738+
{
739+
"id": str(parent_uuid),
740+
"name": "Parent Span",
741+
"span_type": SpanType.CHAIN,
742+
"inputs": {},
743+
"outputs": None,
744+
"start_time": datetime.now(tz=timezone.utc),
745+
"end_time": datetime.now(tz=timezone.utc),
746+
"latency_ms": 10,
747+
"status": SpanStatus.OK,
748+
"error": None,
749+
"attributes": {},
750+
"span_source": "component",
751+
},
752+
]
753+
754+
merged_objects = []
755+
mock_session = AsyncMock()
756+
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
757+
mock_session.__aexit__ = AsyncMock(return_value=False)
758+
759+
async def capture_merge(obj):
760+
merged_objects.append(obj)
761+
762+
mock_session.merge = capture_merge
763+
764+
with patch("lfx.services.deps.session_scope", return_value=mock_session):
765+
await tracer._flush_to_database()
766+
767+
from langflow.services.database.models.traces.model import SpanTable
768+
769+
span_objects = [o for o in merged_objects if isinstance(o, SpanTable)]
770+
assert len(span_objects) == 2
771+
# Parent (no parent_span_id) must come before child
772+
assert span_objects[0].id == parent_uuid
773+
assert span_objects[1].id == child_uuid
774+
assert span_objects[1].parent_span_id == parent_uuid

0 commit comments

Comments
 (0)