diff --git a/README.md b/README.md index 13ccd52..a437e15 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,7 @@ Prerequisites: * [AspNet](src/AspNet) - Demonstration of a generic host worker and an ASP.NET workflow starter. * [Bedrock](src/Bedrock) - Orchestrate a chatbot with Amazon Bedrock. * [ClientMtls](src/ClientMtls) - How to use client certificate authentication, e.g. for Temporal Cloud. +* [Gcp/CloudRun/WorkerId](src/Gcp/CloudRun/WorkerId) - Run a long-lived Temporal Worker on a Google Cloud Run worker pool, deriving the worker identity from Cloud Run metadata. * [ContextPropagation](src/ContextPropagation) - Context propagation via interceptors. * [CounterInterceptor](src/CounterInterceptor/) - Simple Workflow and Client Interceptors example. * [DependencyInjection](src/DependencyInjection) - How to inject dependencies in activities and use generic hosts for workers diff --git a/TemporalioSamples.sln b/TemporalioSamples.sln index a3ede5c..8ab220c 100644 --- a/TemporalioSamples.sln +++ b/TemporalioSamples.sln @@ -141,6 +141,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "SearchAttributes", "SearchA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.SearchAttributes", "src\SearchAttributes\TemporalioSamples.SearchAttributes.csproj", "{97376F57-BA10-464B-AFBD-583E187DE947}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TemporalioSamples.Gcp.CloudRun.WorkerId", "src\Gcp\CloudRun\WorkerId\TemporalioSamples.Gcp.CloudRun.WorkerId.csproj", "{995322ED-0CF3-40F5-B521-380A9591DACC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -799,6 +801,18 @@ Global {97376F57-BA10-464B-AFBD-583E187DE947}.Release|x64.Build.0 = Release|Any CPU {97376F57-BA10-464B-AFBD-583E187DE947}.Release|x86.ActiveCfg = Release|Any CPU {97376F57-BA10-464B-AFBD-583E187DE947}.Release|x86.Build.0 = Release|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Debug|x64.ActiveCfg = Debug|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Debug|x64.Build.0 = Debug|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Debug|x86.ActiveCfg = Debug|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Debug|x86.Build.0 = Debug|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Release|Any CPU.Build.0 = Release|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Release|x64.ActiveCfg = Release|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Release|x64.Build.0 = Release|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Release|x86.ActiveCfg = Release|Any CPU + {995322ED-0CF3-40F5-B521-380A9591DACC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/Gcp/CloudRun/WorkerId/Activities.cs b/src/Gcp/CloudRun/WorkerId/Activities.cs new file mode 100644 index 0000000..2ba663b --- /dev/null +++ b/src/Gcp/CloudRun/WorkerId/Activities.cs @@ -0,0 +1,16 @@ +namespace TemporalioSamples.Gcp.CloudRun.WorkerId; + +using Microsoft.Extensions.Logging; +using Temporalio.Activities; + +public static class Activities +{ + [Activity] + public static string SayHello(string name) + { + ActivityExecutionContext.Current.Logger.LogInformation( + "SayHello activity invoked with name: {Name}", + name); + return $"Hello, {name}!"; + } +} diff --git a/src/Gcp/CloudRun/WorkerId/Program.cs b/src/Gcp/CloudRun/WorkerId/Program.cs new file mode 100644 index 0000000..9aa8ec7 --- /dev/null +++ b/src/Gcp/CloudRun/WorkerId/Program.cs @@ -0,0 +1,81 @@ +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Temporalio.Client; +using Temporalio.Extensions.Gcp.CloudRun.WorkerId; +using Temporalio.Worker; +using TemporalioSamples.Gcp.CloudRun.WorkerId; + +// Cloud Run injects these via `--set-env-vars`; fall back to a local dev server for convenience. +// @@@SNIPSTART dotnet-cloud-run-worker-id +var address = GetEnvironmentVariable("TEMPORAL_ADDRESS") ?? "localhost:7233"; +var temporalNamespace = GetEnvironmentVariable("TEMPORAL_NAMESPACE") ?? "default"; +var taskQueue = GetEnvironmentVariable("TEMPORAL_TASK_QUEUE") ?? "cloud-run-worker-sample"; + +using var loggerFactory = LoggerFactory.Create(builder => builder. + AddSimpleConsole(options => options.TimestampFormat = "[HH:mm:ss] "). + SetMinimumLevel(LogLevel.Information)); +var logger = loggerFactory.CreateLogger("CloudRunWorkerId"); + +// Register the Cloud Run plugin once on the client. At connect time it reads the Cloud Run instance +// id from the metadata server, and the worker pool / service name and revision from the environment, +// then sets the client Identity to the worker identity "{instanceId}@{revision}" (unless one was +// already configured). Every worker created from this client inherits that identity. The plugin only +// sets the worker identity; it does not configure anything else. +// +// NOTE: this requires the process to be running on a Cloud Run worker pool or service. Running it +// elsewhere throws at connect time because the metadata server is unreachable. +var clientOptions = new TemporalClientConnectOptions(address) +{ + Namespace = temporalNamespace, + LoggerFactory = loggerFactory, + Plugins = new[] { new WorkerIdPlugin() }, +}; + +var client = await TemporalClient.ConnectAsync(clientOptions); +// @@@SNIPEND + +// The plugin already applied this identity to the client above; read the metadata directly to log +// the worker identity this process runs under. +var metadata = await GoogleCloudRunMetadata.FetchAsync(); +logger.LogInformation("Cloud Run worker identity: {WorkerIdentity}", metadata.WorkerIdentity); + +var workerOptions = new TemporalWorkerOptions(taskQueue). + AddWorkflow(). + AddActivity(Activities.SayHello); + +using var worker = new TemporalWorker(client, workerOptions); + +// Cloud Run sends SIGTERM before stopping an instance; shut the worker down gracefully on that and +// on Ctrl+C so in-flight tasks can finish. +using var cancellationSource = new CancellationTokenSource(); +Console.CancelKeyPress += (_, eventArgs) => +{ + eventArgs.Cancel = true; + cancellationSource.Cancel(); +}; +using var sigterm = PosixSignalRegistration.Create( + PosixSignal.SIGTERM, + context => + { + context.Cancel = true; + cancellationSource.Cancel(); + }); + +logger.LogInformation( + "Worker started on task queue '{TaskQueue}' against {Address} (namespace '{Namespace}').", + taskQueue, + address, + temporalNamespace); +try +{ + await worker.ExecuteAsync(cancellationSource.Token); +} +catch (OperationCanceledException) +{ + logger.LogInformation("Shutdown signal received; worker stopped."); +} + +static string? GetEnvironmentVariable(string name) => + Environment.GetEnvironmentVariable(name) is { } value && !string.IsNullOrWhiteSpace(value) + ? value + : null; diff --git a/src/Gcp/CloudRun/WorkerId/README.md b/src/Gcp/CloudRun/WorkerId/README.md new file mode 100644 index 0000000..a8ab613 --- /dev/null +++ b/src/Gcp/CloudRun/WorkerId/README.md @@ -0,0 +1,146 @@ +# Cloud Run Worker Identity + +This sample demonstrates how to run a long-lived Temporal Worker on +[Google Cloud Run](https://cloud.google.com/run) and derive its worker identity +from Cloud Run metadata using the `Temporalio.Extensions.Gcp.CloudRun.WorkerId` +package. + +The sample registers a greeting Workflow and Activity and polls until the +container is stopped. + +## How it works + +Unlike the AWS Lambda extension, Cloud Run runs a long-lived container, so this +is a metadata-driven plugin rather than a worker wrapper. The sample registers a +single `WorkerIdPlugin` on `TemporalClientConnectOptions.Plugins`. At connect +time the plugin reads the instance id from the Cloud Run metadata server and the +name and revision from the environment, then sets the client `Identity` to +`{instanceId}@{revision}` (only when an identity is not already set). Every +Worker created from that client inherits the identity. + +The name and revision come from the environment that Cloud Run injects: + +| Value | Worker pool variable | Service variable | +| -------- | ----------------------- | ---------------- | +| Name | `CLOUD_RUN_WORKER_POOL` | `K_SERVICE` | +| Revision | `CLOUD_RUN_REVISION` | `K_REVISION` | + +The worker-pool variables take precedence, so the same code works on a Cloud Run +[worker pool](https://cloud.google.com/run/docs/deploy-worker-pools) or a Cloud +Run service. + +`Program.cs` also reads `GoogleCloudRunMetadata` directly and logs its +`WorkerIdentity`, so the identity the Worker runs under is visible in the logs. + +### Why worker pools + +A Cloud Run **worker pool** runs one or more long-lived container instances that +receive no inbound HTTP requests and are not scaled by request traffic, which is +exactly the shape of a Temporal Worker that polls a Task Queue. Each instance +gets a stable worker identity from its Cloud Run instance id and revision. + +## Unreleased dependency + +`Temporalio.Extensions.Gcp.CloudRun.WorkerId` is not published to NuGet yet, so this +sample cannot be built or deployed from a released package. To let it build +locally, `TemporalioSamples.Gcp.CloudRun.WorkerId.csproj` references the SDK from a +sibling checkout of [sdk-dotnet](https://github.com/temporalio/sdk-dotnet) laid +out next to this repository: + +```text +temporalio/ + samples-dotnet/ # this repo + sdk-dotnet-2/ # https://github.com/temporalio/sdk-dotnet on branch cloud-run-worker-id +``` + +Once the package is released, delete the temporary item groups in the `.csproj` +(the `Temporalio*` `PackageReference Remove` entries and the `ProjectReference` +entries) and replace them with: + +```xml + + + +``` + +This is why the pull request that adds this sample is a draft. + +## Prerequisites + +- A [Temporal Cloud](https://temporal.io/cloud) namespace, or a self-hosted + Temporal cluster the worker pool can reach +- A Google Cloud project with billing enabled and the Cloud Run API enabled +- The [`gcloud` CLI](https://cloud.google.com/sdk/docs/install), authenticated + (`gcloud auth login`) with the project set (`gcloud config set project ...`) +- The [Temporal CLI](https://docs.temporal.io/cli) +- .NET 8 to build locally + +## Configuration + +The worker reads its connection settings from the environment; Cloud Run injects +these through `--set-env-vars`: + +| Variable | Default | Description | +| --------------------- | -------------------------- | ----------------------------------- | +| `TEMPORAL_ADDRESS` | `localhost:7233` | Temporal frontend `host:port`. | +| `TEMPORAL_NAMESPACE` | `default` | Temporal namespace. | +| `TEMPORAL_TASK_QUEUE` | `cloud-run-worker-sample` | Task Queue the worker polls. | + +This sample uses a plaintext connection for brevity. For Temporal Cloud, add API +key / mTLS configuration to `Program.cs` before deploying. + +## 1. Deploy to a Cloud Run worker pool + +Deploy the sample as a worker pool from source (Cloud Build packages the +container with the .NET buildpack). Worker pools are currently a preview feature +and may require the `beta`/`alpha` track: + +```bash +export REGION="us-central1" +export WORKER_POOL="temporal-dotnet-worker" + +gcloud run worker-pools deploy "$WORKER_POOL" \ + --source . \ + --region "$REGION" \ + --set-env-vars \ +TEMPORAL_ADDRESS=..tmprl.cloud:7233,TEMPORAL_NAMESPACE=.,TEMPORAL_TASK_QUEUE=cloud-run-worker-sample +``` + +Run this from `src/Gcp/CloudRun/WorkerId`. Cloud Run sets `CLOUD_RUN_WORKER_POOL` +to `$WORKER_POOL` and `CLOUD_RUN_REVISION` to the revision it creates, which the +plugin turns into the worker identity `{instanceId}@{revision}`. + +## 2. Start a Workflow + +Start the greeting Workflow on the same Task Queue and wait for the result: + +```bash +temporal workflow execute \ + --task-queue cloud-run-worker-sample \ + --type SampleWorkflow \ + --workflow-id cloud-run-worker-sample-1 \ + --input '"Cloud Run"' +``` + +A successful run returns `"Hello, Cloud Run!"`. + +## 3. Clean up + +Delete the worker pool: + +```bash +gcloud run worker-pools delete "$WORKER_POOL" --region "$REGION" +``` + +## Build locally + +With the sibling `sdk-dotnet-2` checkout in place (see +[Unreleased dependency](#unreleased-dependency)): + +```bash +dotnet build src/Gcp/CloudRun/WorkerId +``` + +Running the worker outside Cloud Run fails at startup because the Cloud Run +metadata server is unreachable; deploy it to a worker pool (or service) to run +it. diff --git a/src/Gcp/CloudRun/WorkerId/SampleWorkflow.workflow.cs b/src/Gcp/CloudRun/WorkerId/SampleWorkflow.workflow.cs new file mode 100644 index 0000000..ce42ac1 --- /dev/null +++ b/src/Gcp/CloudRun/WorkerId/SampleWorkflow.workflow.cs @@ -0,0 +1,21 @@ +namespace TemporalioSamples.Gcp.CloudRun.WorkerId; + +using Microsoft.Extensions.Logging; +using Temporalio.Workflows; + +// A minimal greeting workflow that runs one activity. The WorkerIdPlugin only sets the worker +// identity, so this workflow runs exactly as it would on any other worker. +[Workflow] +public class SampleWorkflow +{ + [WorkflowRun] + public async Task RunAsync(string name) + { + Workflow.Logger.LogInformation("SampleWorkflow started with name: {Name}", name); + var result = await Workflow.ExecuteActivityAsync( + () => Activities.SayHello(name), + new() { StartToCloseTimeout = TimeSpan.FromSeconds(10) }); + Workflow.Logger.LogInformation("SampleWorkflow completed with result: {Result}", result); + return result; + } +} diff --git a/src/Gcp/CloudRun/WorkerId/TemporalioSamples.Gcp.CloudRun.WorkerId.csproj b/src/Gcp/CloudRun/WorkerId/TemporalioSamples.Gcp.CloudRun.WorkerId.csproj new file mode 100644 index 0000000..b942dd1 --- /dev/null +++ b/src/Gcp/CloudRun/WorkerId/TemporalioSamples.Gcp.CloudRun.WorkerId.csproj @@ -0,0 +1,32 @@ + + + + Exe + + + + + + + + + + + + + + +