2323import io .temporal .workflow .shared .TestWorkflows ;
2424import java .time .Duration ;
2525import java .util .UUID ;
26+ import java .util .concurrent .atomic .AtomicInteger ;
2627import org .junit .Assert ;
2728import org .junit .BeforeClass ;
2829import org .junit .Rule ;
@@ -75,51 +76,76 @@ public void describeWithoutRunIdTargetsLatest() {
7576 Assert .assertEquals (started .getNexusOperationRunId (), description .getRunId ());
7677 }
7778
79+ // The cancel call just requests the handler to cancel.
80+ // It doesn't automatically cancel. So we are testing not that it
81+ // cancelled the operation, but checking the number of cancel
82+ // invokations the test server received to make sure it increments.
7883 @ Test
7984 public void cancelSucceedsForStartedOperation () {
80- UntypedNexusOperationHandle handle = startPendingOperation ();
81- handle .cancel ();
82- assertCancellationRecorded ( handle , /* expectedReason= */ null );
85+ int before = TestNexusServiceImpl . cancelInvocations . get ();
86+ startPendingOperation () .cancel ();
87+ assertCancelDelivered ( before );
8388 }
8489
8590 @ Test
8691 public void cancelWithReasonSucceedsForStartedOperation () {
87- UntypedNexusOperationHandle handle = startPendingOperation ();
88- handle .cancel ("test-cancel-reason" );
89- assertCancellationRecorded ( handle , "test-cancel-reason" );
92+ int before = TestNexusServiceImpl . cancelInvocations . get ();
93+ startPendingOperation () .cancel ("test-cancel-reason" );
94+ assertCancelDelivered ( before );
9095 }
9196
9297 @ Test
9398 public void cancelWithNullReasonSucceeds () {
94- UntypedNexusOperationHandle handle = startPendingOperation ();
95- handle .cancel (null );
96- assertCancellationRecorded (handle , /* expectedReason= */ null );
99+ int before = TestNexusServiceImpl .cancelInvocations .get ();
100+ startPendingOperation ().cancel (null );
101+ assertCancelDelivered (before );
102+ }
103+
104+ /**
105+ * Polls the handler's invocation counter to confirm the cancel RPC reached the worker and the
106+ * handler's {@code cancel(...)} callback ran (the dispatch is asynchronous — server schedules a
107+ * cancel task, worker polls it, then the callback fires).
108+ */
109+ private static void assertCancelDelivered (int countBeforeCancel ) {
110+ long deadlineNanos = System .nanoTime () + Duration .ofSeconds (8 ).toNanos ();
111+ while (TestNexusServiceImpl .cancelInvocations .get () <= countBeforeCancel
112+ && System .nanoTime () < deadlineNanos ) {
113+ try {
114+ Thread .sleep (100 );
115+ } catch (InterruptedException e ) {
116+ Thread .currentThread ().interrupt ();
117+ throw new RuntimeException (e );
118+ }
119+ }
120+ Assert .assertTrue (
121+ "cancel RPC was not delivered to the handler within the poll budget" ,
122+ TestNexusServiceImpl .cancelInvocations .get () > countBeforeCancel );
97123 }
98124
99125 @ Test
100126 public void terminateSucceedsForStartedOperation () {
101127 UntypedNexusOperationHandle handle = startPendingOperation ();
102128 handle .terminate ();
103- assertTerminatedFailure (handle );
129+ assertTerminalFailure (handle );
104130 }
105131
106132 @ Test
107133 public void terminateWithReasonSucceedsForStartedOperation () {
108134 UntypedNexusOperationHandle handle = startPendingOperation ();
109135 handle .terminate ("test-terminate-reason" );
110- assertTerminatedFailure (handle );
136+ assertTerminalFailure (handle );
111137 }
112138
113139 @ Test
114140 public void terminateWithNullReasonSucceeds () {
115141 UntypedNexusOperationHandle handle = startPendingOperation ();
116142 handle .terminate (null );
117- assertTerminatedFailure (handle );
143+ assertTerminalFailure (handle );
118144 }
119145
120146 /**
121- * Async operations stay in RUNNING state until something external transitions them — used by
122- * cancel/terminate tests so the lifecycle RPC has a non-terminal operation to act on.
147+ * Starts an operation whose handler returns an async-started result without ever completing, so
148+ * the lifecycle RPCs have a non-terminal operation to act on.
123149 */
124150 private UntypedNexusOperationHandle startPendingOperation () {
125151 return startOperation (TestNexusServiceImpl .ASYNC_PREFIX + UUID .randomUUID ());
@@ -130,7 +156,7 @@ private UntypedNexusOperationHandle startPendingOperation() {
130156 * operation to TERMINATED regardless of handler state, so {@code getResult} promptly throws
131157 * {@link NexusOperationFailedException}.
132158 */
133- private static void assertTerminatedFailure (UntypedNexusOperationHandle handle ) {
159+ private static void assertTerminalFailure (UntypedNexusOperationHandle handle ) {
134160 try {
135161 handle .getResult (15 , java .util .concurrent .TimeUnit .SECONDS , String .class );
136162 Assert .fail ("expected getResult to throw after the operation was terminated" );
@@ -141,22 +167,6 @@ private static void assertTerminatedFailure(UntypedNexusOperationHandle handle)
141167 }
142168 }
143169
144- /**
145- * Cancel does NOT auto-transition the operation per the proto contract (handler cooperation is
146- * required). Assert via {@code describe()} that the cancellation request was at least recorded
147- * server-side.
148- */
149- private static void assertCancellationRecorded (
150- UntypedNexusOperationHandle handle , @ javax .annotation .Nullable String expectedReason ) {
151- NexusOperationExecutionDescription description = handle .describe ();
152- Assert .assertNotNull (
153- "expected cancellation_info to be recorded after cancel()" ,
154- description .getCancellationInfo ());
155- if (expectedReason != null ) {
156- Assert .assertEquals (expectedReason , description .getCancellationInfo ().getReason ());
157- }
158- }
159-
160170 @ Test
161171 public void getResultReturnsTypedResultForSyncOperation () {
162172 String result = NexusOperationHandle .fromUntyped (startOperation (), String .class ).getResult ();
@@ -228,6 +238,14 @@ public static class TestNexusServiceImpl {
228238 */
229239 static final String ASYNC_PREFIX = "ASYNC:" ;
230240
241+ /**
242+ * Incremented every time the worker invokes the handler's {@code cancel(...)} callback. The
243+ * cancel tests poll this counter to verify the cancel RPC was delivered end-to-end (client →
244+ * server → worker), even though the no-op cancel doesn't drive the operation to a terminal
245+ * state.
246+ */
247+ static final AtomicInteger cancelInvocations = new AtomicInteger ();
248+
231249 @ OperationImpl
232250 public OperationHandler <String , String > operation () {
233251 return new OperationHandler <String , String >() {
@@ -243,16 +261,18 @@ public OperationStartResult<String> start(
243261 if (input != null && input .startsWith (ASYNC_PREFIX )) {
244262 // Async-started: server keeps the operation in RUNNING state until something
245263 // external (terminate, cancellation that takes effect, or schedule-to-close)
246- // transitions it.
264+ // transitions it. Terminate is server-forced so it transitions reliably; cancel is
265+ // cooperative and won't transition without a backing entity.
247266 return OperationStartResult .async ("token-" + UUID .randomUUID ());
248267 }
249268 return OperationStartResult .sync ("echo:" + (input == null ? "<null>" : input ));
250269 }
251270
252271 @ Override
253272 public void cancel (OperationContext context , OperationCancelDetails details ) {
254- // No-op. Tests assert cancellation visibility via describe()'s CancellationInfo
255- // rather than driving the operation to a terminal state from the handler.
273+ // Record delivery for the cancel tests; otherwise a no-op. Driving the operation to a
274+ // terminal CANCELED state would require a backing entity (e.g. a workflow).
275+ cancelInvocations .incrementAndGet ();
256276 }
257277 };
258278 }
0 commit comments