From 8ce0cf473c136f557f2fee6793ad427b07500a4c Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Tue, 3 Nov 2026 09:35:21 +0400 Subject: [PATCH] feat: add activity/standalone feature A client starts an activity without a workflow, awaits its result and describes the execution. Go, Java, TypeScript, Python, .NET, Ruby and Rust; frontend.activityAPIsEnabled enabled in the shared dynamic config. --- dockerfiles/dynamicconfig/docker.yaml | 3 + features/activity/standalone/README.md | 11 ++++ features/activity/standalone/config.json | 3 + features/activity/standalone/feature.cs | 35 +++++++++++ features/activity/standalone/feature.go | 42 +++++++++++++ features/activity/standalone/feature.java | 56 +++++++++++++++++ features/activity/standalone/feature.py | 42 +++++++++++++ features/activity/standalone/feature.rb | 40 ++++++++++++ features/activity/standalone/feature.rs | 63 +++++++++++++++++++ features/activity/standalone/feature.ts | 29 +++++++++ features/features.go | 2 + .../temporal/sdkfeatures/PreparedFeature.java | 1 + 12 files changed, 327 insertions(+) create mode 100644 features/activity/standalone/README.md create mode 100644 features/activity/standalone/config.json create mode 100644 features/activity/standalone/feature.cs create mode 100644 features/activity/standalone/feature.go create mode 100644 features/activity/standalone/feature.java create mode 100644 features/activity/standalone/feature.py create mode 100644 features/activity/standalone/feature.rb create mode 100644 features/activity/standalone/feature.rs create mode 100644 features/activity/standalone/feature.ts diff --git a/dockerfiles/dynamicconfig/docker.yaml b/dockerfiles/dynamicconfig/docker.yaml index 4cc5e363..4856e0ee 100644 --- a/dockerfiles/dynamicconfig/docker.yaml +++ b/dockerfiles/dynamicconfig/docker.yaml @@ -22,3 +22,6 @@ frontend.enableUpdateWorkflowExecutionAsyncAccepted: frontend.workerVersioningWorkflowAPIs: - value: true constraints: {} +frontend.activityAPIsEnabled: + - value: true + constraints: {} diff --git a/features/activity/standalone/README.md b/features/activity/standalone/README.md new file mode 100644 index 00000000..ca0343e1 --- /dev/null +++ b/features/activity/standalone/README.md @@ -0,0 +1,11 @@ +# Standalone activity + +A client executes an activity directly, without a workflow. + +Each feature: + +* Starts the activity with the client on the feature task queue +* Waits for the activity result +* Describes the activity execution and confirms the `Completed` status + +Requires a server with the dynamic config `frontend.activityAPIsEnabled=true`, enabled in `dockerfiles/dynamicconfig/docker.yaml`. diff --git a/features/activity/standalone/config.json b/features/activity/standalone/config.json new file mode 100644 index 00000000..e9d09c09 --- /dev/null +++ b/features/activity/standalone/config.json @@ -0,0 +1,3 @@ +{ + "noWorkflow": true +} diff --git a/features/activity/standalone/feature.cs b/features/activity/standalone/feature.cs new file mode 100644 index 00000000..6418fe07 --- /dev/null +++ b/features/activity/standalone/feature.cs @@ -0,0 +1,35 @@ +namespace activity.standalone; + +using Temporalio.Activities; +using Temporalio.Api.Enums.V1; +using Temporalio.Client; +using Temporalio.Features.Harness; +using Temporalio.Worker; + +class Feature : IFeature +{ + public void ConfigureWorker(Runner runner, TemporalWorkerOptions options) => + options.AddAllActivities(null); + + public async Task ExecuteAsync(Runner runner) + { + var handle = await runner.Client.StartActivityAsync( + () => MyActivities.Echo("hello"), + new(Guid.NewGuid().ToString(), runner.WorkerOptions.TaskQueue!) + { + StartToCloseTimeout = TimeSpan.FromMinutes(1), + }); + + Assert.Equal("hello", await handle.GetResultAsync()); + + var desc = await handle.DescribeAsync(); + Assert.Equal(ActivityExecutionStatus.Completed, desc.Status); + return null; + } + + class MyActivities + { + [Activity] + public static Task Echo(string value) => Task.FromResult(value); + } +} diff --git a/features/activity/standalone/feature.go b/features/activity/standalone/feature.go new file mode 100644 index 00000000..310aac0b --- /dev/null +++ b/features/activity/standalone/feature.go @@ -0,0 +1,42 @@ +package standalone + +import ( + "context" + "time" + + "github.com/google/uuid" + "github.com/temporalio/features/harness/go/harness" + "go.temporal.io/api/enums/v1" + "go.temporal.io/sdk/client" + "go.temporal.io/sdk/workflow" +) + +var Feature = harness.Feature{ + Workflows: Workflow, + Activities: Echo, + Execute: Execute, +} + +func Workflow(workflow.Context) error { return nil } + +func Echo(_ context.Context, value string) (string, error) { + return value, nil +} + +func Execute(ctx context.Context, r *harness.Runner) (client.WorkflowRun, error) { + handle, err := r.Client.ExecuteActivity(ctx, client.StartActivityOptions{ + ID: uuid.NewString(), + TaskQueue: r.TaskQueue, + StartToCloseTimeout: 1 * time.Minute, + }, Echo, "hello") + r.Require.NoError(err) + + var result string + r.Require.NoError(handle.Get(ctx, &result)) + r.Require.Equal("hello", result) + + desc, err := handle.Describe(ctx, client.DescribeActivityOptions{}) + r.Require.NoError(err) + r.Require.Equal(enums.ACTIVITY_EXECUTION_STATUS_COMPLETED, desc.Status) + return nil, nil +} diff --git a/features/activity/standalone/feature.java b/features/activity/standalone/feature.java new file mode 100644 index 00000000..55956173 --- /dev/null +++ b/features/activity/standalone/feature.java @@ -0,0 +1,56 @@ +package activity.standalone; + +import io.temporal.activity.ActivityInterface; +import io.temporal.activity.ActivityMethod; +import io.temporal.api.enums.v1.ActivityExecutionStatus; +import io.temporal.client.ActivityClient; +import io.temporal.client.ActivityClientOptions; +import io.temporal.client.StartActivityOptions; +import io.temporal.sdkfeatures.Feature; +import io.temporal.sdkfeatures.Run; +import io.temporal.sdkfeatures.Runner; +import io.temporal.sdkfeatures.SimpleWorkflow; +import java.time.Duration; +import java.util.UUID; +import org.junit.jupiter.api.Assertions; + +@ActivityInterface +public interface feature extends Feature, SimpleWorkflow { + @ActivityMethod + String echo(String value); + + class Impl implements feature { + @Override + public void workflow() {} + + @Override + public String echo(String value) { + return value; + } + + @Override + public Run execute(Runner runner) { + var client = + ActivityClient.newInstance( + runner.service, + ActivityClientOptions.newBuilder().setNamespace(runner.config.namespace).build()); + + var handle = + client.start( + feature.class, + feature::echo, + StartActivityOptions.newBuilder() + .setId(UUID.randomUUID().toString()) + .setTaskQueue(runner.config.taskQueue) + .setStartToCloseTimeout(Duration.ofMinutes(1)) + .build(), + "hello"); + + Assertions.assertEquals("hello", handle.getResult()); + Assertions.assertEquals( + ActivityExecutionStatus.ACTIVITY_EXECUTION_STATUS_COMPLETED, + handle.describe().getStatus()); + return null; + } + } +} diff --git a/features/activity/standalone/feature.py b/features/activity/standalone/feature.py new file mode 100644 index 00000000..7cee373f --- /dev/null +++ b/features/activity/standalone/feature.py @@ -0,0 +1,42 @@ +import uuid +from datetime import timedelta + +from temporalio import activity, workflow +from temporalio.client import ActivityExecutionStatus, WorkflowHandle + +from harness.python.feature import Runner, register_feature + + +@workflow.defn +class Workflow: + @workflow.run + async def run(self) -> None: + pass + + +@activity.defn +async def echo(value: str) -> str: + return value + + +async def start(runner: Runner) -> WorkflowHandle: + handle = await runner.client.start_activity( + echo, + "hello", + id=str(uuid.uuid4()), + task_queue=runner.task_queue, + start_to_close_timeout=timedelta(minutes=1), + ) + assert await handle.result() == "hello" + + desc = await handle.describe() + assert desc.status == ActivityExecutionStatus.COMPLETED + + return await runner.start_single_parameterless_workflow() + + +register_feature( + workflows=[Workflow], + activities=[echo], + start=start, +) diff --git a/features/activity/standalone/feature.rb b/features/activity/standalone/feature.rb new file mode 100644 index 00000000..35f14c61 --- /dev/null +++ b/features/activity/standalone/feature.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'securerandom' + +require 'temporalio/activity' +require 'temporalio/client' + +require 'harness' + +class StandaloneEchoActivity < Temporalio::Activity::Definition + def execute(value) + value + end +end + +start = proc do |client, task_queue, _feature| + client.start_activity( + StandaloneEchoActivity, + 'hello', + id: SecureRandom.uuid, + task_queue: task_queue, + start_to_close_timeout: 60 + ) +end + +check_result = proc do |handle, _feature| + result = handle.result + raise "Expected result \"hello\", got #{result.inspect}" unless result == 'hello' + + status = handle.describe.status + completed = Temporalio::Client::ActivityExecutionStatus::COMPLETED + raise "Expected status #{completed}, got #{status}" unless status == completed +end + +Harness.register_feature( + workflows: [], + activities: [StandaloneEchoActivity], + start: start, + check_result: check_result +) diff --git a/features/activity/standalone/feature.rs b/features/activity/standalone/feature.rs new file mode 100644 index 00000000..cf53cea0 --- /dev/null +++ b/features/activity/standalone/feature.rs @@ -0,0 +1,63 @@ +use crate::harness::{Feature, FeatureContext, FeatureFuture, FeatureOutcome}; +use anyhow::{Result, ensure}; +use std::time::Duration; +use temporalio_client::{ + ActivityDescribeOptions, ActivityExecutionInfoLike, ActivityExecutionStatus, + ActivityStartOptions, +}; +use temporalio_macros::activities; +use temporalio_sdk::{ + WorkerOptions, + activities::{ActivityContext, ActivityError}, +}; + +pub struct Activities; + +#[activities] +impl Activities { + #[activity] + pub async fn echo(_ctx: ActivityContext, value: String) -> Result { + Ok(value) + } +} + +struct StandaloneActivity; + +impl Feature for StandaloneActivity { + fn worker_options(&self, ctx: &FeatureContext) -> Result> { + Ok(Some( + WorkerOptions::new(ctx.task_queue.clone()) + .register_activities(Activities) + .build(), + )) + } + + fn execute<'a>(&'a self, ctx: &'a FeatureContext) -> FeatureFuture<'a> { + Box::pin(async move { + let options = ActivityStartOptions::with_start_to_close_timeout( + ctx.task_queue.clone(), + ctx.task_queue.clone(), + Duration::from_secs(60), + ) + .build(); + let handle = ctx + .client + .start_activity(Activities::echo, "hello".to_owned(), options) + .await?; + ensure!( + handle.result().await? == "hello", + "unexpected activity result" + ); + let description = handle.describe(ActivityDescribeOptions::default()).await?; + ensure!( + description.status() == ActivityExecutionStatus::Completed, + "unexpected activity status" + ); + Ok(FeatureOutcome::Passed) + }) + } +} + +pub fn feature() -> Box { + Box::new(StandaloneActivity) +} diff --git a/features/activity/standalone/feature.ts b/features/activity/standalone/feature.ts new file mode 100644 index 00000000..6ff308af --- /dev/null +++ b/features/activity/standalone/feature.ts @@ -0,0 +1,29 @@ +import * as assert from 'assert'; +import { randomUUID } from 'crypto'; +import { Feature } from '@temporalio/harness'; + +export async function workflow(): Promise {} + +const activities = { + async echo(value: string): Promise { + return value; + }, +}; + +export const feature = new Feature({ + workflow, + activities, + alternateRun: async (runner) => { + const handle = await runner.client.activity.start('echo', { + id: randomUUID(), + taskQueue: runner.options.taskQueue, + startToCloseTimeout: '1 minute', + args: ['hello'], + }); + + assert.equal(await handle.result(), 'hello'); + + const desc = await handle.describe(); + assert.equal(desc.status, 'COMPLETED'); + }, +}); diff --git a/features/features.go b/features/features.go index 889331d6..50b45dc0 100644 --- a/features/features.go +++ b/features/features.go @@ -5,6 +5,7 @@ import ( activity_cancel_try_cancel "github.com/temporalio/features/features/activity/cancel_try_cancel" activity_retry_on_error "github.com/temporalio/features/features/activity/retry_on_error" activity_shutdown "github.com/temporalio/features/features/activity/shutdown" + activity_standalone "github.com/temporalio/features/features/activity/standalone" bugs_go_activity_start_race "github.com/temporalio/features/features/bugs/go/activity_start_race" bugs_go_child_workflow_cancel_panic "github.com/temporalio/features/features/bugs/go/child_workflow_cancel_panic" build_id_versioning_activity_and_child_on_correct_version "github.com/temporalio/features/features/build_id_versioning/activity_and_child_on_correct_version" @@ -81,6 +82,7 @@ func init() { activity_cancel_try_cancel.Feature, activity_retry_on_error.Feature, activity_shutdown.Feature, + activity_standalone.Feature, bugs_go_activity_start_race.Feature, bugs_go_child_workflow_cancel_panic.Feature, build_id_versioning_activity_and_child_on_correct_version.Feature, diff --git a/harness/java/io/temporal/sdkfeatures/PreparedFeature.java b/harness/java/io/temporal/sdkfeatures/PreparedFeature.java index 1ee2adea..650d2b31 100644 --- a/harness/java/io/temporal/sdkfeatures/PreparedFeature.java +++ b/harness/java/io/temporal/sdkfeatures/PreparedFeature.java @@ -10,6 +10,7 @@ public class PreparedFeature { activity.retry_on_error.feature.Impl.class, activity.cancel_try_cancel.feature.Impl.class, activity.shutdown.feature.Impl.class, + activity.standalone.feature.Impl.class, child_workflow.result.feature.Impl.class, child_workflow.signal.feature.Impl.class, client.http_proxy.feature.Impl.class,