Skip to content

Standalone Activities for Java#2858

Open
GregoryTravis wants to merge 45 commits intomasterfrom
gmt/java-standalone-activities
Open

Standalone Activities for Java#2858
GregoryTravis wants to merge 45 commits intomasterfrom
gmt/java-standalone-activities

Conversation

@GregoryTravis
Copy link
Copy Markdown
Contributor

@GregoryTravis GregoryTravis commented Apr 22, 2026

Add standalone activity API (ActivityClient)

Introduces support for standalone activities — activities that execute independently of any workflow.

New public API surface:

  • ActivityClient — top-level client for starting, describing,
    listing, counting, cancelling, and terminating standalone activities
  • ActivityHandle<R> / UntypedActivityHandle — typed and untyped
    handles returned by ActivityClient.start(); provide getResult(),
    getResultAsync(), describe(), cancel(), and terminate()
  • StartActivityOptions — builder-based options for starting an
    activity (id, task queue, timeouts, retry, priority, etc.)
  • ActivityClientOptions — namespace / data converter / interceptor
    configuration for ActivityClient
  • ActivityExecutionDescription — rich descriptor returned by
    describe() and list*(); extends ActivityExecutionMetadata
  • ActivityExecutionMetadata — lightweight metadata used in list
    results
  • ActivityExecutionCount — result of countActivities()
  • ActivityListOptions / ActivityListPaginatedOptions — filtering
    and pagination options for list operations
  • ActivityListPage — page of results with continuation token
  • ActivityAlreadyStartedException — thrown when a duplicate activity
    id is rejected by the server
  • ActivityFailedException — thrown from getResult() when the
    activity fails

New interceptor API:

  • ActivityClientCallsInterceptor — per-call interceptor for all
    ActivityClient operations
  • ActivityClientCallsInterceptorBase — pass-through base
    implementation (delegates every method to the next interceptor)
  • ActivityClientInterceptor — factory interceptor that wraps the
    client-level invoker
  • ActivityClientInterceptorBase — no-op base implementation

ActivityInfo additions:

  • getActivityRunId() — run-scoped id assigned by the server to each
    activity execution
  • isWorkflowActivity() — distinguishes workflow-dispatched activities
    from standalone ones

ActivityCompletionClient additions:

  • New overloads taking (String activityId, Optional<String> runId, …)
    for completing, failing, sending heartbeats, and cancelling standalone
    activities without a workflow id

ActivitySerializationContext fix:

  • workflowId and workflowType are now @Nullable; removed
    requireNonNull guards that caused NPEs for standalone activities

Functions.java:

  • Added Func and VFunc (zero-arg typed/void functional interfaces)
    needed by ActivityClient.start() method-reference overloads

CI:

  • Enable standalone-activity server feature flags in the Temporal CLI
    dev server used by unit tests:
    frontend.activityAPIsEnabled, activity.enableStandalone,
    history.enableChasm, history.enableTransitionHistory

Tests:

  • StandaloneActivityTest — integration tests against a real server
    covering the full activity lifecycle (start, poll, complete, cancel,
    terminate, describe, list, heartbeat, async completion)
  • ActivityClientCallsInterceptorBaseTest — delegation tests for the
    base interceptor
  • ActivityClientCallsInterceptorChainTest — chain ordering tests
  • ActivityHandleImplTest — unit tests for handle dispatch
  • ActivityCompletionClientImplTest — unit tests for completion client
  • ActivityInfoStandaloneTest — unit tests for ActivityInfo in the
    standalone context
  • ActivitySerializationContextTest — confirms nullable workflow fields
  • StartActivityOptionsTest, ActivityClientOptionsTest,
    ActivityExecutionDescriptionTest, ActivityExecutionMetadataTest,
    ActivityAlreadyStartedExceptionTest — options and value-type tests

Remove isEmpty() check from isWorkflowActivity.
@GregoryTravis GregoryTravis changed the title Gmt/java standalone activities Standalone Activities for Java Apr 23, 2026

@Override
public <R> CompletableFuture<R> getResultAsync(Class<R> resultClass, @Nullable Type resultType) {
return CompletableFuture.supplyAsync(() -> getResult(resultClass, resultType));
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we want to set up a separate Executor for this?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably do. I think you might need to do something similar to what happens in the workflow client for getting results (and we probably also want overloads that you can give timeouts to, like it has).

@Quinn-With-Two-Ns I'm sure would be able to say something much more intelligent than me about this. I'd ask him.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also probably want to cache results here rather than re-polling. Looks like the other SDKs are doing that.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah we shouldn't just be calling getResult in supplyAsync , look at how getResultAsync on WorkflowStubImpl works you need to wire the async down to the GRPC layer

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

and yeah if we cahced results in other SDK's we should here as well

@GregoryTravis GregoryTravis marked this pull request as ready for review April 23, 2026 18:36
@GregoryTravis GregoryTravis requested a review from a team as a code owner April 23, 2026 18:36
Copy link
Copy Markdown
Member

@Sushisource Sushisource left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Overall looking pretty good to me! I didn't review the tests (it's a long one!). Just a few things to address (and we'll want more than my stamp).

* {@code captured[0]} when any method is called on it.
*/
@SuppressWarnings("unchecked")
private static <I> I createTypeProbe(Class<I> activityInterface, Method[] captured) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is captured an array here? Seems like only one value can ever be written? I guess this is just a workaround for needing some wrapper type, but I wonder if there's something more explicit for that purpose.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like AtomicReference is the standard approach, but I think that has as semantic connotation I don't want to give, so I'm just adding a Box.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got rid of Box and moved all the logic to MethodExtractor, to reduce boilerplate here.

Comment on lines +95 to +98
} catch (Throwable ignored) {
}
UntypedActivityHandle untyped =
start(extractActivityType(activityInterface, captured[0]), options, new Object[0]);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like, in general with these createTypeProbe call sites, we could be doing error handling a bit more robustly. If they pass a method reference that doesn't exist on the class, for example, what kind of error are we gonna throw here?

Will we just end up with an NPE from dereffing captured[0]? We want to provide at least some kind of reasonable user-facing error in that case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved the logic into MethodExtractor, which throws an information RTE if the method doesn't match the activity.

Comment thread temporal-sdk/src/main/java/io/temporal/client/ActivityClientImpl.java Outdated
* ActivityHandle#describe()}.
*/
@Experimental
public final class ActivityExecutionDescription extends ActivityExecutionMetadata {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This guy is missing lastFailure() which exists in the other SDKs. Probably also good to expose the raw info too, which I think we typically do?


@Override
public <R> CompletableFuture<R> getResultAsync(Class<R> resultClass, @Nullable Type resultType) {
return CompletableFuture.supplyAsync(() -> getResult(resultClass, resultType));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably do. I think you might need to do something similar to what happens in the workflow client for getting results (and we probably also want overloads that you can give timeouts to, like it has).

@Quinn-With-Two-Ns I'm sure would be able to say something much more intelligent than me about this. I'd ask him.


@Override
public <R> CompletableFuture<R> getResultAsync(Class<R> resultClass, @Nullable Type resultType) {
return CompletableFuture.supplyAsync(() -> getResult(resultClass, resultType));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We also probably want to cache results here rather than re-polling. Looks like the other SDKs are doing that.

}

@Override
public DescribeActivityOutput describeActivity(DescribeActivityInput input) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this will need to support the long poll token, which probably also means the describe() on the handle needs an overload to take one too

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the SDK wasn't/didn't need to expose the long poll token

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bleh, I forgot that. I was just going on what existed in Go/Python. We can probably leave it out then.

/**
* Starts a standalone activity using a typed interface and an unbound method reference, and
* returns a typed handle. The activity type name is inferred from the method reference at runtime
* via a reflection proxy; the result type is captured from the generic type parameter.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
* via a reflection proxy; the result type is captured from the generic type parameter.

IMO this is just an implementation detail, I don't think the average user would benefit from that info and might even confuse them since a "reflection proxy" isn't a standard term. The only argument I could see is for GRAAL users, since they do need to know that.

*
* <p>Example:
*
* <pre>{@code
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I like the examples a lot, but I would make 2 small changes. I would add one for each overload, or ask AI to write them, and have just one example per method.

* @param options pagination options such as page size
* @return a page of results and a token for the next page
*/
ActivityListPage listExecutionsPaginated(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think we need to expose this? We didn't for other list methods.

A2 arg2,
A3 arg3)
throws ActivityAlreadyStartedException {
Method method = MethodExtractor.extract(activityInterface, activity);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Evanthx FYI your SANO code could copy this pattern.

*/
<I> ActivityHandle<Void> start(
Class<I> activityInterface, Functions.Proc1<I> activity, StartActivityOptions options)
throws ActivityAlreadyStartedException;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
throws ActivityAlreadyStartedException;

Generally the Java SDK does NOT used checked exceptions. I think we should be consistent with other client calls.

* StartActivityOptions#getIdConflictPolicy()}).
*/
@Experimental
public final class ActivityAlreadyStartedException extends TemporalException {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For WorkflowExecutionAlreadyStarted we inherit from WorkflowExecution I don't see anything equivalent in this PR.

* @param resultType the generic type to use for deserialization; may be {@code null}
* @throws ActivityFailedException if the activity failed, timed out, or was cancelled
*/
<R> R getResult(Class<R> resultClass, @Nullable Type resultType) throws ActivityFailedException;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<R> R getResult(Class<R> resultClass, @Nullable Type resultType) throws ActivityFailedException;
<R> R getResult(Class<R> resultClass, @Nullable Type resultType);

Same comment as above about checked exception

* cancelled. The original cause can be retrieved via {@link #getCause()}.
*/
@Experimental
public final class ActivityFailedException extends TemporalException {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we are missing a common ActivityException base class here that ActivityFailedException and ActivityAlreadyStartedException

@Experimental
public interface ActivityClientCallsInterceptor {

StartActivityOutput startActivity(StartActivityInput input)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: we should have Java docs for these. I know some interceptors don't but that is more tech debt then intentional IMO.

* ActivityHandle#fromUntyped(UntypedActivityHandle, Class)} or {@link
* ActivityHandle#fromUntyped(UntypedActivityHandle, Class, Type)}.
*/
final class ActivityHandleWrapper<R> implements ActivityHandle<R> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
final class ActivityHandleWrapper<R> implements ActivityHandle<R> {
final class ActivityHandleImpl<R> implements ActivityHandle<R> {

Was there a reason we used a fifferent naming convention then other classes?

* @param resultClass the class to deserialize the result into
* @param resultType the generic type to use for deserialization; may be {@code null}
*/
<R> CompletableFuture<R> getResultAsync(Class<R> resultClass, @Nullable Type resultType);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are missing the timeour overloads for getResultAsync on WorkflowStub

Copy link
Copy Markdown
Contributor

@Quinn-With-Two-Ns Quinn-With-Two-Ns left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't review the test or check more subtle behaviour, overall I think it looks really good! The MethodExtractor was a good idea. A few changes that if I owned the Java SDK I would want changed is:

  • Mirror the exception hierarchy with WorkflowException
  • Mirror the getResultAsync overloads in WorkflowStub
  • Remove the use of checked exceptions, they are not used in the Java SDK like this
  • Mirror the implementation of getResultAsync from WorkflowStub. getResultAsync should not but be calling getResult in an executor it should flow down to an async gRPC call.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants