Skip to content

Commit 777bb7f

Browse files
committed
Wait for idle instead of polling traced queries in LangSmith tests
TestComprehensiveTracing and the plugin end-to-end test waited for the workflow to reach its signal wait by polling is_waiting_for_signal every second through the traced client, then asserted that every QueryWorkflow run had exactly one HandleQuery child. Temporal does not guarantee that: a query issued while a workflow task is pending or started is buffered by the server and attached to every workflow task start until it completes, including the retry after the task times out or its completion is rejected, so the worker evaluates it twice (two HandleQuery runs). If the client's 30s RPC timeout fires first, the server drops the query before it is dispatched at all (no HandleQuery run). CI logs of the failing runs show exactly this: a QueryWorkflow call lasting 29.998s followed by "Error reporting WFT to server" for the test workflow. The same stalls made the unguarded raw query fail with "Timeout expired" and, once a task reached attempt 3, made the server reject updates with "Workflow Task in failed state". Readiness is now observed through the raw client's describe(), which is not traced and involves no workflow task, and the traced query is issued exactly once after the workflow is idle, so it is dispatched directly as a query task and cannot be re-attached to a retried workflow task. Updates are only sent once the workflow is idle again after the signal. The query assertion becomes an exact hierarchy check instead of a loop over a variable number of polls.
1 parent fa04bc6 commit 777bb7f

2 files changed

Lines changed: 77 additions & 75 deletions

File tree

‎tests/contrib/langsmith/test_integration.py‎

Lines changed: 59 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,12 @@
1616
from temporalio import activity, common, nexus, workflow
1717
from temporalio.client import (
1818
Client,
19+
WorkflowExecutionStatus,
1920
WorkflowFailureError,
2021
WorkflowHandle,
21-
WorkflowQueryFailedError,
2222
)
2323
from temporalio.contrib.langsmith import LangSmithPlugin
2424
from temporalio.exceptions import ApplicationError
25-
from temporalio.service import RPCError
2625
from temporalio.testing import WorkflowEnvironment
2726
from tests.contrib.langsmith.conftest import (
2827
InMemoryRunCollector,
@@ -312,22 +311,28 @@ def _make_temporal_client(
312311
return Client(**config)
313312

314313

315-
@traceable(name="poll_query")
316-
async def _poll_query(
317-
handle: WorkflowHandle[Any, Any],
318-
query: Callable[..., Any],
319-
*,
320-
expected: Any = True,
321-
) -> bool:
322-
"""Poll a workflow query until it returns the expected value."""
314+
async def _wait_for_workflow_idle(handle: WorkflowHandle[Any, Any]) -> None:
315+
"""Wait until the running workflow has no pending task, activity, child or Nexus op."""
323316
while True:
324-
try:
325-
result = await handle.query(query)
326-
if result == expected:
327-
return True
328-
except (WorkflowQueryFailedError, RPCError):
329-
pass # Query not yet available (workflow hasn't started)
330-
await asyncio.sleep(1)
317+
desc = await handle.describe()
318+
assert desc.status == WorkflowExecutionStatus.RUNNING, desc.status
319+
raw = desc.raw_description
320+
if not (
321+
raw.HasField("pending_workflow_task")
322+
or raw.pending_activities
323+
or raw.pending_children
324+
or raw.pending_nexus_operations
325+
):
326+
return
327+
await asyncio.sleep(0.2)
328+
329+
330+
@traceable(name="query_pipeline")
331+
async def _query_pipeline(
332+
handle: WorkflowHandle[Any, Any], query: Callable[..., Any]
333+
) -> Any:
334+
"""Run a single workflow query under a @traceable root."""
335+
return await handle.query(query)
331336

332337

333338
# ---------------------------------------------------------------------------
@@ -563,7 +568,7 @@ async def test_comprehensive_with_temporal_runs(
563568
"""Full trace hierarchy with worker restart mid-workflow.
564569
565570
user_pipeline only wraps start_workflow (completing before the worker
566-
starts), so poll/signal/query traces are naturally separate root traces.
571+
starts), so query/signal/update traces are naturally separate root traces.
567572
"""
568573
if env.supports_time_skipping:
569574
pytest.skip("Time-skipping server doesn't persist headers.")
@@ -575,6 +580,8 @@ async def test_comprehensive_with_temporal_runs(
575580
temporal_client_1 = _make_temporal_client(
576581
client, mock_ls, add_temporal_runs=True
577582
)
583+
# Raw-client handle (no LangSmith interceptor) for untraced readiness checks
584+
raw_handle = client.get_workflow_handle(workflow_id)
578585

579586
@traceable(name="user_pipeline")
580587
async def user_pipeline() -> WorkflowHandle[Any, Any]:
@@ -588,7 +595,7 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
588595
# Start workflow — no worker yet, just a server RPC
589596
handle = await user_pipeline()
590597

591-
# Phase 1: worker picks up workflow, poll until signal wait
598+
# Phase 1: worker runs the workflow up to the signal wait
592599
async with new_worker(
593600
temporal_client_1,
594601
ComprehensiveWorkflow,
@@ -602,14 +609,14 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
602609
make_nexus_endpoint_name(worker.task_queue),
603610
worker.task_queue,
604611
)
605-
assert await _poll_query(
606-
handle,
607-
ComprehensiveWorkflow.is_waiting_for_signal,
608-
expected=True,
612+
await _wait_for_workflow_idle(raw_handle)
613+
assert await _query_pipeline(
614+
handle, ComprehensiveWorkflow.is_waiting_for_signal
609615
), "Workflow never reached signal wait point"
610-
# Raw-client query (no LangSmith interceptor) — root-level trace
611-
raw_handle = client.get_workflow_handle(workflow_id)
612-
await raw_handle.query(ComprehensiveWorkflow.is_waiting_for_signal)
616+
# Raw-client query — root-level trace
617+
assert await raw_handle.query(
618+
ComprehensiveWorkflow.is_waiting_for_signal
619+
)
613620

614621
# Phase 2: fresh worker, signal to resume, complete
615622
temporal_client_2 = _make_temporal_client(
@@ -627,6 +634,7 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
627634
handle_2 = temporal_client_2.get_workflow_handle(workflow_id)
628635
await handle_2.query(ComprehensiveWorkflow.my_query)
629636
await handle_2.signal(ComprehensiveWorkflow.my_signal, "hello")
637+
await _wait_for_workflow_idle(raw_handle)
630638
await handle_2.execute_update(
631639
ComprehensiveWorkflow.my_unvalidated_update, "test"
632640
)
@@ -704,18 +712,15 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
704712
]
705713
assert_trace_hierarchy(workflow_trace_trees, expected_workflow)
706714

707-
# poll_query trace (separate root, variable number of iterations)
708-
poll_trace_trees = find_trace_trees(trace_trees, "poll_query")
709-
assert len(poll_trace_trees) == 1
710-
poll = poll_trace_trees[0]
711-
assert poll.name == "poll_query"
712-
poll_children = poll.children
713-
for poll_child in poll_children:
714-
assert poll_child.name == "QueryWorkflow:is_waiting_for_signal"
715-
assert [child.name for child in poll_child.children] == [
716-
"HandleQuery:is_waiting_for_signal"
717-
]
718-
assert not poll_child.children[0].children
715+
# query_pipeline trace: the worker-side handler nests under the client query
716+
assert_trace_hierarchy(
717+
find_trace_trees(trace_trees, "query_pipeline"),
718+
[
719+
"query_pipeline",
720+
" QueryWorkflow:is_waiting_for_signal",
721+
" HandleQuery:is_waiting_for_signal",
722+
],
723+
)
719724

720725
# Raw-client query — no parent context, appears as root
721726
raw_query_trace_trees = [
@@ -777,7 +782,7 @@ async def test_comprehensive_without_temporal_runs(
777782
"""Same workflow with add_temporal_runs=False and worker restart.
778783
779784
Only @traceable runs appear. Context propagation via headers still works.
780-
user_pipeline only wraps start_workflow, so poll traces are separate roots.
785+
user_pipeline only wraps start_workflow, so the query trace is a separate root.
781786
"""
782787
if env.supports_time_skipping:
783788
pytest.skip("Time-skipping server doesn't persist headers.")
@@ -789,6 +794,8 @@ async def test_comprehensive_without_temporal_runs(
789794
temporal_client_1 = _make_temporal_client(
790795
client, mock_ls, add_temporal_runs=False
791796
)
797+
# Raw-client handle (no LangSmith interceptor) for untraced readiness checks
798+
raw_handle = client.get_workflow_handle(workflow_id)
792799

793800
@traceable(name="user_pipeline")
794801
async def user_pipeline() -> WorkflowHandle[Any, Any]:
@@ -801,7 +808,7 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
801808
with tracing_context(client=mock_ls, enabled=True):
802809
handle = await user_pipeline()
803810

804-
# Phase 1: worker picks up workflow, poll until signal wait
811+
# Phase 1: worker runs the workflow up to the signal wait
805812
async with new_worker(
806813
temporal_client_1,
807814
ComprehensiveWorkflow,
@@ -815,13 +822,13 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
815822
make_nexus_endpoint_name(worker.task_queue),
816823
worker.task_queue,
817824
)
825+
await _wait_for_workflow_idle(raw_handle)
818826
# Raw-client query — no interceptor, produces nothing
819-
raw_handle = client.get_workflow_handle(workflow_id)
820-
await raw_handle.query(ComprehensiveWorkflow.is_waiting_for_signal)
821-
assert await _poll_query(
822-
handle,
823-
ComprehensiveWorkflow.is_waiting_for_signal,
824-
expected=True,
827+
assert await raw_handle.query(
828+
ComprehensiveWorkflow.is_waiting_for_signal
829+
)
830+
assert await _query_pipeline(
831+
handle, ComprehensiveWorkflow.is_waiting_for_signal
825832
), "Workflow never reached signal wait point"
826833

827834
# Phase 2: fresh worker, signal to resume, complete
@@ -839,6 +846,7 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
839846
):
840847
handle_2 = temporal_client_2.get_workflow_handle(workflow_id)
841848
await handle_2.signal(ComprehensiveWorkflow.my_signal, "hello")
849+
await _wait_for_workflow_idle(raw_handle)
842850
await handle_2.execute_update(
843851
ComprehensiveWorkflow.my_unvalidated_update, "test"
844852
)
@@ -883,10 +891,10 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
883891
]
884892
assert_trace_hierarchy(workflow_trace_trees, expected_workflow)
885893

886-
# Poll query — separate root, just the @traceable wrapper, no Temporal children
887-
poll_trace_trees = find_trace_trees(trace_trees, "poll_query")
888-
assert len(poll_trace_trees) == 1
889-
assert_trace_hierarchy(poll_trace_trees, ["poll_query"])
894+
# Query — separate root, just the @traceable wrapper, no Temporal children
895+
assert_trace_hierarchy(
896+
find_trace_trees(trace_trees, "query_pipeline"), ["query_pipeline"]
897+
)
890898

891899

892900
# ---------------------------------------------------------------------------
@@ -1252,13 +1260,7 @@ async def test_temporal_prefixed_query_not_traced(
12521260
task_queue=worker.task_queue,
12531261
)
12541262

1255-
# Wait for workflow to start by polling the user query
1256-
assert await _poll_query(
1257-
handle,
1258-
QueryFilteringWorkflow.my_query,
1259-
expected="query-result",
1260-
), "Workflow never started"
1261-
1263+
await _wait_for_workflow_idle(handle)
12621264
collector.clear()
12631265

12641266
# Built-in queries — should NOT be traced

‎tests/contrib/langsmith/test_plugin.py‎

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
NexusService,
2222
TraceableActivityWorkflow,
2323
_make_client_and_collector,
24-
_poll_query,
24+
_query_pipeline,
25+
_wait_for_workflow_idle,
2526
nested_traceable_activity,
2627
traceable_activity,
2728
)
@@ -63,7 +64,7 @@ async def test_comprehensive_plugin_trace_hierarchy(
6364
) -> None:
6465
"""Plugin wired to a real Temporal worker produces the full trace hierarchy.
6566
66-
user_pipeline only wraps start_workflow, so poll/query/signal/update
67+
user_pipeline only wraps start_workflow, so query/signal/update
6768
traces are naturally separate root traces.
6869
"""
6970
if env.supports_time_skipping:
@@ -100,13 +101,15 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
100101
make_nexus_endpoint_name(worker.task_queue),
101102
worker.task_queue,
102103
)
103-
assert await _poll_query(
104-
handle,
105-
ComprehensiveWorkflow.is_waiting_for_signal,
106-
expected=True,
104+
# Raw-client handle (no LangSmith interceptor) for untraced readiness checks
105+
raw_handle = client.get_workflow_handle(workflow_id)
106+
await _wait_for_workflow_idle(raw_handle)
107+
assert await _query_pipeline(
108+
handle, ComprehensiveWorkflow.is_waiting_for_signal
107109
), "Workflow never reached signal wait point"
108110
await handle.query(ComprehensiveWorkflow.my_query)
109111
await handle.signal(ComprehensiveWorkflow.my_signal, "hello")
112+
await _wait_for_workflow_idle(raw_handle)
110113
await handle.execute_update(
111114
ComprehensiveWorkflow.my_unvalidated_update, "test"
112115
)
@@ -184,18 +187,15 @@ async def user_pipeline() -> WorkflowHandle[Any, Any]:
184187
]
185188
assert_trace_hierarchy(workflow_trace_trees, expected_workflow)
186189

187-
# poll_query trace (separate root, variable number of iterations)
188-
poll_trace_trees = find_trace_trees(trace_trees, "poll_query")
189-
assert len(poll_trace_trees) == 1
190-
poll = poll_trace_trees[0]
191-
assert poll.name == "poll_query"
192-
poll_children = poll.children
193-
for poll_child in poll_children:
194-
assert poll_child.name == "QueryWorkflow:is_waiting_for_signal"
195-
assert [child.name for child in poll_child.children] == [
196-
"HandleQuery:is_waiting_for_signal"
197-
]
198-
assert not poll_child.children[0].children
190+
# query_pipeline trace: the worker-side handler nests under the client query
191+
assert_trace_hierarchy(
192+
find_trace_trees(trace_trees, "query_pipeline"),
193+
[
194+
"query_pipeline",
195+
" QueryWorkflow:is_waiting_for_signal",
196+
" HandleQuery:is_waiting_for_signal",
197+
],
198+
)
199199

200200
# Each remaining operation is its own root trace
201201
query_trace_trees = find_trace_trees(trace_trees, "QueryWorkflow:my_query")

0 commit comments

Comments
 (0)