Skip to content

Commit c4e7062

Browse files
Send SANO links in requests (#1621)
* Remove filtering of nexus links when sending requests. Refactor some test helpers into shared helpers. * update changelog
1 parent ca62ed2 commit c4e7062

6 files changed

Lines changed: 237 additions & 55 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ to include examples, links to docs, or any other relevant information.
3434
loop.
3535
- Relaxed the protobuf dependency bounds to allow protobuf 7 where compatible
3636
with the selected optional dependencies.
37+
- Standalone Nexus operation links are now forwarded on start workflow and signal requests.
3738

3839
### Deprecated
3940

temporalio/client/_impl.py

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -217,28 +217,18 @@ async def _build_start_workflow_execution_request(
217217
if input.request_id:
218218
req.request_id = input.request_id
219219

220-
# Server currently only supports workflow_event and batch_job
221-
# link types. This filter should be removed or adapted as
222-
# server-side support comes online.
223-
# See https://github.com/temporalio/temporal/issues/10345
224-
links = [
225-
link
226-
for link in input.links
227-
if link.HasField("workflow_event") or link.HasField("batch_job")
228-
]
229-
230220
req.completion_callbacks.extend(
231221
temporalio.api.common.v1.Callback(
232222
nexus=temporalio.api.common.v1.Callback.Nexus(
233223
url=callback.url,
234224
header=callback.headers,
235225
),
236-
links=links,
226+
links=input.links,
237227
)
238228
for callback in input.callbacks
239229
)
240230
# Links are duplicated on request for compatibility with older server versions.
241-
req.links.extend(links)
231+
req.links.extend(input.links)
242232

243233
nexus_ctx = temporalio.nexus._operation_context._try_start_operation_context()
244234
if nexus_ctx is not None:

tests/helpers/nexus.py

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,102 @@
1+
from collections.abc import Sequence
2+
3+
import temporalio.api.common.v1
4+
import temporalio.api.enums.v1
5+
import temporalio.api.history.v1
6+
from temporalio.client import WorkflowHistory
7+
8+
19
def make_nexus_endpoint_name(task_queue: str) -> str:
210
# Create endpoints for different task queues without name collisions.
311
return f"nexus-endpoint-{task_queue}"
12+
13+
14+
def events_of_type(
15+
history: WorkflowHistory,
16+
event_type: temporalio.api.enums.v1.EventType.ValueType,
17+
) -> list[temporalio.api.history.v1.HistoryEvent]:
18+
return [event for event in history.events if event.event_type == event_type]
19+
20+
21+
def links_from_workflow_execution_started_event(
22+
event: temporalio.api.history.v1.HistoryEvent,
23+
) -> list[temporalio.api.common.v1.Link]:
24+
callback_links = [
25+
link
26+
for callback in event.workflow_execution_started_event_attributes.completion_callbacks
27+
for link in callback.links
28+
]
29+
if callback_links:
30+
return list(callback_links)
31+
return list(event.links)
32+
33+
34+
def workflow_event_link_event_type(
35+
workflow_event: temporalio.api.common.v1.Link.WorkflowEvent,
36+
) -> temporalio.api.enums.v1.EventType.ValueType:
37+
if workflow_event.HasField("request_id_ref"):
38+
return workflow_event.request_id_ref.event_type
39+
return workflow_event.event_ref.event_type
40+
41+
42+
def expected_nexus_operation_link(
43+
*,
44+
namespace: str,
45+
operation_id: str,
46+
run_id: str,
47+
) -> temporalio.api.common.v1.Link:
48+
return temporalio.api.common.v1.Link(
49+
nexus_operation=temporalio.api.common.v1.Link.NexusOperation(
50+
namespace=namespace,
51+
operation_id=operation_id,
52+
run_id=run_id,
53+
)
54+
)
55+
56+
57+
def expected_workflow_event_link(
58+
*,
59+
namespace: str,
60+
workflow_id: str,
61+
run_id: str,
62+
event_type: temporalio.api.enums.v1.EventType.ValueType,
63+
event_id: int = 0,
64+
request_id: str | None = None,
65+
) -> temporalio.api.common.v1.Link:
66+
if request_id is not None:
67+
return temporalio.api.common.v1.Link(
68+
workflow_event=temporalio.api.common.v1.Link.WorkflowEvent(
69+
namespace=namespace,
70+
workflow_id=workflow_id,
71+
run_id=run_id,
72+
request_id_ref=temporalio.api.common.v1.Link.WorkflowEvent.RequestIdReference(
73+
request_id=request_id,
74+
event_type=event_type,
75+
),
76+
)
77+
)
78+
79+
return temporalio.api.common.v1.Link(
80+
workflow_event=temporalio.api.common.v1.Link.WorkflowEvent(
81+
namespace=namespace,
82+
workflow_id=workflow_id,
83+
run_id=run_id,
84+
event_ref=temporalio.api.common.v1.Link.WorkflowEvent.EventReference(
85+
event_id=event_id,
86+
event_type=event_type,
87+
),
88+
)
89+
)
90+
91+
92+
def assert_links_match(
93+
links: Sequence[temporalio.api.common.v1.Link],
94+
*expected_links: temporalio.api.common.v1.Link,
95+
) -> None:
96+
actual = sorted(list(links), key=_link_sort_key)
97+
expected = sorted(list(expected_links), key=_link_sort_key)
98+
assert actual == expected
99+
100+
101+
def _link_sort_key(link: temporalio.api.common.v1.Link) -> bytes:
102+
return link.SerializeToString(deterministic=True)

tests/nexus/test_signal_link_propagation_e2e.py

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
``history.enableCHASMSignalBacklinks=true`` (added to the local dev-server args in
1515
``tests/conftest.py``). The server populates the backlink's reference via ``RequestIdReference``
1616
rather than ``EventReference``, so backlink assertions tolerate both oneof variants of
17-
``common.v1.Link.WorkflowEvent.reference`` (see ``_backlink_event_type``). When run against a
18-
server that does not emit the backlink, the backward assertions are skipped.
17+
``common.v1.Link.WorkflowEvent.reference`` (see ``workflow_event_link_event_type``). When run
18+
against a server that does not emit the backlink, the backward assertions are skipped.
1919
2020
The forward/backward description above applies to operations scheduled by a caller workflow. The
2121
file also covers the same handlers invoked as standalone (client-initiated) operations via
@@ -41,7 +41,6 @@
4141
)
4242
from nexusrpc.handler._decorators import operation_handler
4343

44-
import temporalio.api.common.v1
4544
import temporalio.api.enums.v1
4645
import temporalio.api.history.v1
4746
import temporalio.common
@@ -51,7 +50,11 @@
5150
from temporalio.testing import WorkflowEnvironment
5251
from temporalio.worker import Worker
5352
from tests.helpers import assert_eventually
54-
from tests.helpers.nexus import make_nexus_endpoint_name
53+
from tests.helpers.nexus import (
54+
events_of_type,
55+
make_nexus_endpoint_name,
56+
workflow_event_link_event_type,
57+
)
5558

5659
EventType = temporalio.api.enums.v1.EventType
5760

@@ -216,29 +219,12 @@ async def run(self, callee_id: str, task_queue: str) -> str:
216219
# ── Assertion helpers ───────────────────────────────────────────────────────────────────────
217220

218221

219-
def _events_of_type(
220-
history: WorkflowHistory,
221-
event_type: temporalio.api.enums.v1.EventType.ValueType,
222-
) -> list[temporalio.api.history.v1.HistoryEvent]:
223-
return [e for e in history.events if e.event_type == event_type]
224-
225-
226-
def _backlink_event_type(
227-
we: temporalio.api.common.v1.Link.WorkflowEvent,
228-
) -> temporalio.api.enums.v1.EventType.ValueType:
229-
# Server PR #9897 keys backlinks via RequestIdReference rather than EventReference; accept
230-
# either oneof variant (matches Java SignalOperationLinkingTest.assertBacklink).
231-
if we.HasField("request_id_ref"):
232-
return we.request_id_ref.event_type
233-
return we.event_ref.event_type
234-
235-
236222
def _assert_forward_link(
237223
callee_history: WorkflowHistory,
238224
caller_id: str,
239225
expected_count: int,
240226
) -> None:
241-
signaled = _events_of_type(
227+
signaled = events_of_type(
242228
callee_history, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED
243229
)
244230
assert len(signaled) == expected_count, (
@@ -267,7 +253,10 @@ def _assert_backlink(
267253
return False
268254
we = event.links[0].workflow_event
269255
assert we.workflow_id == callee_id, "backlink should reference the callee workflow"
270-
assert _backlink_event_type(we) == EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED
256+
assert (
257+
workflow_event_link_event_type(we)
258+
== EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED
259+
)
271260
return True
272261

273262

@@ -310,7 +299,7 @@ async def test_sync_signal_operation_links(
310299
_assert_forward_link(callee_history, caller_id, expected_count=2)
311300

312301
# Backward: the single NexusOperationCompleted carries backlinks to the callee.
313-
completed = _events_of_type(
302+
completed = events_of_type(
314303
caller_history, EventType.EVENT_TYPE_NEXUS_OPERATION_COMPLETED
315304
)
316305
assert len(completed) == 1, (
@@ -360,7 +349,7 @@ async def test_async_signal_operation_links(
360349
_assert_forward_link(callee_history, caller_id, expected_count=1)
361350

362351
# Backward: the backlink lands on NexusOperationStarted for the async response path.
363-
started = _events_of_type(
352+
started = events_of_type(
364353
caller_history, EventType.EVENT_TYPE_NEXUS_OPERATION_STARTED
365354
)
366355
assert len(started) == 1, (
@@ -389,7 +378,7 @@ def _assert_standalone_forward_link(
389378
operation_id: str,
390379
expected_count: int,
391380
) -> None:
392-
signaled = _events_of_type(
381+
signaled = events_of_type(
393382
callee_history, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED
394383
)
395384
assert len(signaled) == expected_count, (
@@ -549,7 +538,7 @@ async def test_start_from_handler_attaches_on_conflict_options(
549538
assert await callee_handle.result() == "done"
550539
callee_history = await callee_handle.fetch_history()
551540

552-
updated = _events_of_type(
541+
updated = events_of_type(
553542
callee_history, EventType.EVENT_TYPE_WORKFLOW_EXECUTION_OPTIONS_UPDATED
554543
)
555544
if not updated:

tests/nexus/test_standalone_operations.py

Lines changed: 113 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
sync_operation,
2222
)
2323

24+
import temporalio.api.enums.v1
2425
from temporalio import nexus, workflow
2526
from temporalio.client import (
2627
CancelNexusOperationInput,
@@ -36,6 +37,7 @@
3637
OutboundInterceptor,
3738
StartNexusOperationInput,
3839
TerminateNexusOperationInput,
40+
WorkflowHistory,
3941
WorkflowUpdateStage,
4042
)
4143
from temporalio.common import (
@@ -56,7 +58,13 @@
5658
from temporalio.testing import WorkflowEnvironment
5759
from temporalio.worker import Worker
5860
from tests.helpers import assert_eventually
59-
from tests.helpers.nexus import make_nexus_endpoint_name
61+
from tests.helpers.nexus import (
62+
assert_links_match,
63+
expected_nexus_operation_link,
64+
expected_workflow_event_link,
65+
links_from_workflow_execution_started_event,
66+
make_nexus_endpoint_name,
67+
)
6068

6169
# ---------------------------------------------------------------------------
6270
# Data types
@@ -259,6 +267,61 @@ async def test_start_async_operation_and_poll_result(
259267
assert result.value == "async-hello"
260268

261269

270+
async def test_started_workflow_has_link_to_standalone_nexus_operation(
271+
client: Client, env: WorkflowEnvironment
272+
):
273+
"""Start a workflow_run operation and verify its workflow links back to the Nexus op."""
274+
if env.supports_time_skipping:
275+
pytest.skip(
276+
"Standalone Nexus Operation tests don't work with time-skipping server"
277+
)
278+
279+
task_queue = str(uuid.uuid4())
280+
endpoint_name = make_nexus_endpoint_name(task_queue)
281+
service_handler = StandaloneTestServiceHandler()
282+
283+
async with Worker(
284+
client,
285+
task_queue=task_queue,
286+
nexus_service_handlers=[service_handler],
287+
workflows=[EchoHandlerWorkflow, BlockingHandlerWorkflow],
288+
):
289+
await env.create_nexus_endpoint(endpoint_name, task_queue)
290+
291+
nexus_client = client.create_nexus_client(
292+
service=StandaloneTestService, endpoint=endpoint_name
293+
)
294+
op_id = str(uuid.uuid4())
295+
input_value = f"link-test-{uuid.uuid4()}"
296+
workflow_id = f"blocking_async-{input_value}"
297+
298+
handle = await nexus_client.start_operation(
299+
StandaloneTestService.blocking_async,
300+
EchoInput(value=input_value),
301+
id=op_id,
302+
id_reuse_policy=NexusOperationIDReusePolicy.REJECT_DUPLICATE,
303+
id_conflict_policy=NexusOperationIDConflictPolicy.FAIL,
304+
schedule_to_close_timeout=timedelta(seconds=30),
305+
)
306+
307+
await service_handler.started_blocking.wait()
308+
workflow_history = await _assert_workflow_started_with_nexus_operation_link(
309+
client, workflow_id, handle
310+
)
311+
await _assert_nexus_operation_has_link_to_started_workflow(
312+
client, workflow_history, handle
313+
)
314+
315+
workflow_handle = client.get_workflow_handle(workflow_id)
316+
await workflow_handle.start_update(
317+
BlockingHandlerWorkflow.unblock,
318+
wait_for_stage=WorkflowUpdateStage.COMPLETED,
319+
)
320+
result = await handle.result()
321+
assert isinstance(result, EchoOutput)
322+
assert result.value == input_value
323+
324+
262325
async def test_execute_operation(client: Client, env: WorkflowEnvironment):
263326
"""Use execute_operation convenience method, verify it returns result directly."""
264327
if env.supports_time_skipping:
@@ -949,3 +1012,52 @@ async def test_interceptor_receives_inputs(client: Client, env: WorkflowEnvironm
9491012
count_input = interceptor.count_calls[-1]
9501013
assert isinstance(count_input, CountNexusOperationsInput)
9511014
assert count_input.query == query
1015+
1016+
1017+
async def _assert_workflow_started_with_nexus_operation_link(
1018+
client: Client,
1019+
workflow_id: str,
1020+
operation_handle: NexusOperationHandle[Any],
1021+
) -> WorkflowHistory:
1022+
history = await client.get_workflow_handle(workflow_id).fetch_history()
1023+
started_event = next(
1024+
(
1025+
e
1026+
for e in history.events
1027+
if (
1028+
e.event_type
1029+
== temporalio.api.enums.v1.EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED
1030+
)
1031+
),
1032+
None,
1033+
)
1034+
assert started_event is not None
1035+
1036+
assert operation_handle.run_id is not None
1037+
assert_links_match(
1038+
links_from_workflow_execution_started_event(started_event),
1039+
expected_nexus_operation_link(
1040+
namespace=client.namespace,
1041+
operation_id=operation_handle.operation_id,
1042+
run_id=operation_handle.run_id,
1043+
),
1044+
)
1045+
return history
1046+
1047+
1048+
async def _assert_nexus_operation_has_link_to_started_workflow(
1049+
client: Client,
1050+
workflow_history: WorkflowHistory,
1051+
operation_handle: NexusOperationHandle[Any],
1052+
) -> None:
1053+
desc = await operation_handle.describe()
1054+
assert_links_match(
1055+
desc.raw_description.links,
1056+
expected_workflow_event_link(
1057+
namespace=client.namespace,
1058+
workflow_id=workflow_history.workflow_id,
1059+
run_id=workflow_history.run_id,
1060+
event_type=temporalio.api.enums.v1.EventType.EVENT_TYPE_WORKFLOW_EXECUTION_STARTED,
1061+
event_id=workflow_history.events[0].event_id,
1062+
),
1063+
)

0 commit comments

Comments
 (0)