diff --git a/docs/develop/dotnet/nexus/activity-backed-operations.mdx b/docs/develop/dotnet/nexus/activity-backed-operations.mdx new file mode 100644 index 0000000000..84c462716f --- /dev/null +++ b/docs/develop/dotnet/nexus/activity-backed-operations.mdx @@ -0,0 +1,179 @@ +--- +id: activity-backed-operations +title: Activity-backed Nexus Operations - .NET SDK +sidebar_label: Activity-backed Operations +description: How to back a Nexus Operation with a Standalone Activity using TemporalOperationHandler in the .NET SDK. +toc_max_heading_level: 4 +slug: /develop/dotnet/nexus/activity-backed-operations +tags: + - Nexus + - .NET SDK + - Activities +--- + +:::caution + +Activity-backed Nexus Operations are pre-release and built on the [Temporal Operation Handler](/nexus/temporal-operation-handler). +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A Nexus Operation can be backed by a [Standalone Activity](/standalone-activity) as well as a Workflow. +Starting the Operation starts an Activity Execution that has no parent Workflow, and the Operation completes when that Activity returns. + +For the conceptual model, see [Nexus Standalone Activity](/nexus/standalone-activity). +This page shows how to implement Activity-backed Operations with `TemporalOperationHandler` in the .NET SDK. + +## When to use an Activity-backed Operation + +The shape fits work that is one durable step sitting behind a team boundary. +You get all of the following without building any of it yourself: + +- **Durability per call.** Retries on the policy you set, timeouts you control, and a record of every attempt including the last error. +- **Deduplicated starts.** A stable Activity Id means a retried or redelivered request targets the same Activity Execution instead of starting a second one, so you do not need a separate store of processed Ids. +- **Protection from a failing dependency.** Repeated retryable failures trip the [circuit breaker](/nexus/operations#circuit-breaking) rather than piling retries onto a system that is already struggling. +- **A boundary between teams.** Callers get scoped access to named Operations, not write access to your Namespace, and they never see your Task Queues, your Workers, or your implementation. +- **Room to change your mind.** As long as the contract holds you can rewrite what runs behind an Operation, including replacing the Activity with a Workflow later, and no caller changes. + +Either calling style works: a caller Workflow can invoke the Operation as one step of a larger process, or a Client can start it directly as a [Standalone Nexus Operation](/standalone-nexus-operation) with no Workflow on either side. + +A sampling of customer use cases this was built to address follows. + +### Durable webhook and event processing without running a queue + +A service ingests webhooks from third-party providers — payment events, repository pushes, delivery receipts, CRM change notifications. +Each one needs to trigger a single durable task: update a downstream system, kick off a notification, index into search, sync into a warehouse. + +Providers retry aggressively if you do not return `200` within seconds, so the receiver has to accept fast and do the work elsewhere. +The usual way to get there is to assemble it yourself: a queue, a consumer fleet, hand-written retry and dead-letter handling, and a separate store holding processed event Ids for deduplication. +That is a lot of infrastructure whose only job is to run one function reliably. + +An Activity-backed Operation replaces the whole assembly. +The receiver starts the Operation and returns `200` immediately; Temporal owns delivery from that point. +The provider's own event Id becomes the Activity Id, so a redelivered webhook targets the same Activity Execution instead of starting a second one, and the deduplication store disappears. +Retries, backoff, and the record of every attempt come from the Activity. +When a downstream dependency starts failing, the [circuit breaker](/nexus/operations#circuit-breaking) trips rather than letting retries pile up against it. + +The team that owns the receiver is usually not the team that owns the processing, and the Nexus Endpoint is where that boundary lands: scoped access to specific Operations rather than write access to a Namespace. + +### Sandboxing a tool call + +An agent needs to call a tool — an MCP tool, an internal API, something that executes untrusted input. +You want that call to run somewhere with its own credentials, its own network reach, and its own blast radius, not inside the process orchestrating the agent. + +An Activity-backed Operation puts a Namespace boundary between the two. +The tool runs on Workers in the Namespace that owns it, under that Namespace's credentials, and the caller only ever sees the Operation contract. +The call is still durable and retried, and it is still traceable end to end, but the caller cannot reach past the contract into the environment the tool runs in. +See [Build AI applications with Temporal](/with-ai) for how this fits alongside the rest of the agent stack. + +### A durable front door to another system + +Any system you call can fail, time out, or be down when you need it — a third-party API, a legacy internal service, an unreliable piece of infrastructure. +Wrapping that call in an Activity-backed Operation makes every call against it durable: retried on your policy, bounded by your timeouts, and recorded whether it succeeded or not. + +Written once and published as a Nexus Service, it becomes a connector that every team calls instead of each writing its own integration. +The team that owns it can fix a bug or change the implementation behind the contract, without a coordinated rollout across every consumer. + +### Related patterns + +The same shape fits anything that is an external trigger, one durable step, and a team boundary. + +- **Asynchronous user actions from a backend-for-frontend.** A user clicks "export my data" or "revoke my sessions"; the BFF starts the Operation and hands the client a handle to poll. +- **Consumer offload.** A Kafka or event-stream consumer starts an Operation, commits its offset, and lets Temporal own durability from there. The event Id is the deduplication key. +- **Platform actions triggered by CI/CD.** A pipeline step requests a compliance scan, a canary step, or a credential rotation. The platform team owns the handler; consumer teams get scoped Endpoint access. +- **Scheduled platform tasks.** A scheduler fires an Operation and a shared platform team's Workers run the task. + +## How it works + +Use `TemporalOperationHandler` and call `startActivity` on the injected Nexus-aware Client. +The handler returns an async result carrying an activity-execution Operation token, and the server delivers the Activity's result to the caller through the Nexus completion callback when the Activity finishes. + +```csharp +[NexusServiceHandler(typeof(IGreetingNexusService))] +public class GreetingNexusServiceHandler +{ + [TemporalOperation] + public Task> Greet( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + GreetingInput input) => + client.StartActivityAsync( + () => GreetingActivities.GreetAsync(input), + new() + { + Id = $"greet-{ctx.RequestId}", + StartToCloseTimeout = TimeSpan.FromSeconds(10), + }); +} +``` + + +You write the Activity the same way whichever side calls it. +The same Activity Function can be executed by a Workflow and started behind a Nexus Operation with no code changes — nothing in its definition is Nexus-specific. +What differs is how it is started, not what it is. + +```csharp +public class GreetingActivities +{ + [Activity] + public static Task GreetAsync(GreetingInput input) => + Task.FromResult(new GreetingOutput($"Hello, {input.Name}")); +} +``` + + +### Required options + +Starting an Activity this way needs values that a Workflow-called Activity does not. + +- **An Activity Id**, unique within the Namespace. There is no parent Workflow to scope it. +- **A timeout.** At least one of start-to-close or schedule-to-close. + +The Task Queue is optional. It defaults to the Task Queue the Operation is running on, which is what the samples above rely on. Set it explicitly to run the Activity on its own Worker fleet rather than the one the Endpoint targets. + +Deriving the Id from the Nexus request Id makes the start idempotent. +The server retries a Nexus start request using the same request Id, so each retry targets the same Activity Id rather than starting a second Activity. + +Setting the Activity Id conflict policy to use-existing attaches to an already-running Activity with that Id instead of failing. +Combined with an Id derived from the Operation *input* rather than the request Id, this lets several Nexus Operations share one Activity Execution and all receive its result. + +### Register the Worker + +Register the Activity implementations and the Nexus Service implementation on a Worker polling the Endpoint's target Task Queue. +There is no Workflow implementation to register. + +```csharp +using var worker = new TemporalWorker( + client, + new TemporalWorkerOptions(TaskQueueName). + AddActivity(GreetingActivities.GreetAsync). + AddNexusService(new GreetingNexusServiceHandler())); +``` + + +## Cancellation + +Worth remembering, because it is the one behavioral difference from a Workflow-backed Operation that surprises people. + +A Workflow is interrupted by a cancellation request. An Activity is not: the Worker only learns about it on the next heartbeat, so an Activity that never heartbeats runs until it completes or hits its timeout, no matter how many cancellation requests the caller sends. + +Nothing about this is Nexus-specific. See [Activity cancellation](/activity-execution#cancellation) for how to heartbeat, what to do with the resulting cancellation exception, and why a heartbeat timeout matters. For a short Activity that finishes well inside its timeout and doesn't have the risk of hanging, however, a heartbeat is not needed. + +## Choose between an Activity and a Workflow + +Back an Operation with an **Activity** when the work is a single step: one external call, one computation, one notification. +You get retries, timeouts, and a durable record of the step, without an Event History tracking orchestration you are not doing. + +Back an Operation with a **Workflow** when the work has more than one step, needs to wait for something, needs to receive [messages](/sending-messages), or needs durable intermediate state. +For example, an approval that blocks for a human decision is a Workflow, not an Activity. + +Sample code: `{code not yet live}` + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for the conceptual model. +- [Temporal Operation Handler](/develop/dotnet/nexus/temporal-operation-handler) for the handler type and the Nexus-aware Client. +- [Standalone Activity](/standalone-activity) for the underlying concept. +- [Standalone Nexus Operation](/standalone-nexus-operation) for starting Operations without a caller Workflow. + +::: diff --git a/docs/develop/dotnet/nexus/temporal-operation-handler.mdx b/docs/develop/dotnet/nexus/temporal-operation-handler.mdx new file mode 100644 index 0000000000..d60fc3dba6 --- /dev/null +++ b/docs/develop/dotnet/nexus/temporal-operation-handler.mdx @@ -0,0 +1,206 @@ +--- +id: temporal-operation-handler +title: Temporal Operation Handler - .NET SDK +sidebar_label: Temporal Operation Handler +description: How to implement Nexus Operations with TemporalOperationHandler in the .NET SDK. +toc_max_heading_level: 4 +slug: /develop/dotnet/nexus/temporal-operation-handler +tags: + - Nexus + - .NET SDK +--- + +:::caution + +The Temporal Operation Handler is pre-release. +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A [Nexus Service](/nexus/services) publishes Operations that other teams call across [Namespace](/namespaces) boundaries. +`TemporalOperationHandler` is how you implement those Operations. + +For the conceptual model, see [Temporal Operation Handler](/nexus/temporal-operation-handler). +This page shows how to write handlers in the .NET SDK, migrate from earlier APIs, and compose Workflow, Update, Signal, and Activity backings. + +## What you can do with it + +**Back an Operation with whichever primitive fits the work.** A multi-step process is a Workflow. A single durable step is an [Activity](/activities), with no Workflow wrapped around it. A change to something already running is an Update. The caller sees the same Operation contract either way, and you can change your choice later without touching callers. + +**Combine messaging and a backing in one handler.** A handler can Signal a running Workflow to unblock it and then return a different Execution's result for the caller to await. These are not separate handler types you pick between; they compose inside one start handler. + +**Get observability across the Namespace boundary without wiring it.** The Client handed to your handler propagates [bidirectional links](/nexus/execution-debugging#bi-directional-linking) and request Ids on every call it makes. The caller-side and handler-side Executions are connected in the UI and in [Event History](/encyclopedia/event-history), so a single trace crosses the boundary between two teams' Namespaces. + +**Stay idempotent through retries.** The server retries Nexus start requests, and the request Id travels with them. Deriving the backing Execution's Id from it means a retry targets the same Execution rather than starting a second one. + +**Cancel through the same handler.** The Operation token records which kind of Execution backs the Operation, so a cancellation request reaches the right place. The default behavior is usually what you want, and each kind can be overridden when it is not. + +**Grow a handler without rewriting it.** An Operation that starts out completing inline can later gain an async backing, or send a Signal, without changing handler type or breaking its contract. + +## The Nexus-aware Client + +A `TemporalOperationHandler` start handler receives three things: a context, a Client, and the Operation input. + +The Client is what makes the linking automatic, so prefer it over constructing your own inside a handler. +Reaching for your own Client still works, but messages sent that way are not connected back to the caller. + +The Client exposes two kinds of call, and the distinction shapes how you write the handler. +The examples below use the .NET SDK APIs. + +**Async backings — at most one per Operation invocation.** These determine what the Operation *is*, and their result is delivered to the caller through the Nexus completion callback when the underlying Execution finishes. + +- Start a Workflow — the Operation completes when the Workflow returns +- Update a Workflow — the Operation completes when the Update completes +- Start an Activity — the Operation completes when the Activity returns; see [Nexus Standalone Activity](/develop/dotnet/nexus/activity-backed-operations) + +**Sync messaging — as many as you need.** Reach these through the underlying Temporal Client that the Nexus-aware Client exposes. +They take effect during the handler call, still get link propagation, and do not require an async backing. + +- Signal — delivered during the handler call to a Workflow that is already running +- Signal-with-Start — delivers the Signal, starting the Workflow first if it is not already running + +A handler that only sends messages returns a synchronous result, and the Operation completes immediately. + +## Write an Operation handler + +The examples below use a Nexus Service with a `startGreeting` Operation backed by a Workflow, an `updateShippingAddress` Operation backed by an Update, a `cancelOrder` Operation that sends a Signal, and a `greet` Operation backed by an Activity. + +### Back an Operation with a Workflow + +Call `startWorkflow` on the Client and return its result. The Operation completes when the Workflow returns, delivering the Workflow's return value to the caller. + +```csharp +[TemporalOperation] +public Task> StartGreeting( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + GreetingInput input) => + client.StartWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync(input), + new() { Id = $"greeting-{input.Name}" }); +``` + + +### Back an Operation with an Update + +Back an Operation with an Update when it changes something already running. The target Workflow has to exist already, and the Operation completes when the Update completes. + +```csharp +[TemporalOperation] +public Task> UpdateShippingAddress( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + UpdateAddressInput input) => + client.StartWorkflowUpdateAsync( + $"order-{input.OrderId}", + wf => wf.UpdateShippingAddressAsync(input), + new() { WaitForStage = WorkflowUpdateStage.Accepted }); +``` + + +Three constraints apply to Update-backed Operations, and the first two fail the Operation rather than degrading: + +- **Only the accepted stage is supported.** A Nexus Operation can only back an asynchronous Update, so the wait-for-stage must be "accepted". +- **The Update Id defaults to the Nexus request Id.** Leaving it unset is what you usually want: a retried start request carries the same request Id, so it targets the same Update rather than running a second one. + +The result is async in the normal case, carrying an update-workflow Operation token. If the Update has already completed by the time it is accepted — a retried request with the same Update Id, or an Update that completes immediately — you get a synchronous result instead. + +### Send a Signal from an Operation + +Reach the Workflow Client through the injected Client, send the message, and return a synchronous result. The Operation completes during the handler call, and the Signal is linked back to the caller. + +```csharp +[TemporalOperation] +public async Task> CancelOrder( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + CancelOrderInput input) +{ + await client.TemporalClient + .GetWorkflowHandle($"order-{input.OrderId}") + .SignalAsync("requestCancellation", new object?[] { input }); + return TemporalOperationResult.SyncResult(default); +} +``` + + +The same Client also offers Signal-with-Start, and a handler may send several messages before returning. + +### Back an Operation with an Activity + +Call `startActivity` when the work is a single durable step. The Activity runs with no parent Workflow, so the options require an Activity Id and at least one timeout. See [Nexus Standalone Activity](/develop/dotnet/nexus/activity-backed-operations). + +```csharp +[TemporalOperation] +public Task> Greet( + TemporalOperationStartContext ctx, + ITemporalNexusClient client, + GreetingInput input) => + client.StartActivityAsync( + () => GreetingActivities.GreetAsync(input), + new() + { + Id = $"greet-{ctx.RequestId}", + StartToCloseTimeout = TimeSpan.FromSeconds(10), + }); +``` + + +## Coming from the earlier handler APIs + +Skip this section if you are new to Nexus. + +Earlier SDK versions had a separate helper per pattern. Existing handlers will keep working, there is no forced migration. + +Operations already in progress are not a concern either. If you need to cancel one, for example, a Workflow-backed Operation started by one of the earlier APIs is cancelled by `TemporalOperationHandler` just as it would have been before. + +| If you used | Use instead | +| --- | --- | +| The Workflow-run helper (`WorkflowRunOperation`, `NewWorkflowRunOperation`, `@workflow_run_operation`) | `TemporalOperationHandler` with a Workflow backing | +| The synchronous handler (`OperationHandler.sync`, `nexus.NewSyncOperation`, `@sync_operation`) | `TemporalOperationHandler` returning a sync result | +| A Workflow wrapping a single Activity | `TemporalOperationHandler` with an Activity backing | +| A Temporal Client fetched inside a handler | The Client injected into the start handler | + +Two things improve when you migrate. Messages and Executions get [bidirectional linking](/nexus/execution-debugging#bi-directional-linking), which hand-fetched Clients do not produce. And one handler type covers every case, so an Operation can change what backs it without changing shape. + +### Migrating a Workflow-backed Operation + +The earlier helper reached the Client through the Operation context and returned a Workflow handle or method reference, rather than being handed a Client and returning an Operation result: + +```csharp +WorkflowRunOperationHandler.FromHandleFactory( + async (context, input) => + await context.StartWorkflowAsync( + (GreetingWorkflow wf) => wf.RunAsync(input), + new() { Id = $"greeting-{input.Name}" })); +``` + + +Replace it with [Back an Operation with a Workflow](#back-an-operation-with-a-workflow). + +### Migrating a synchronous Operation + +A messaging Operation used to be a synchronous handler that fetched its own Client, which is why those messages produced no links: + +```csharp +OperationHandler.Sync(async (ctx, input) => +{ + await NexusOperationExecutionContext.Current.TemporalClient + .GetWorkflowHandle($"order-{input.OrderId}") + .SignalAsync("requestCancellation", new object?[] { input }); + return default; +}); +``` + + +Replace it with [Send a Signal from an Operation](#send-a-signal-from-an-operation). + +:::tip RESOURCES + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the conceptual model. +- [Nexus Services](/nexus/services) and [Nexus Operations](/nexus/operations) for the underlying concepts. +- [Nexus Client Code Generator](/nexus/client-code-generator) for contract-first typed clients (nexgen does not emit .NET). +- [Activity-backed Nexus Operations](/develop/dotnet/nexus/activity-backed-operations) for Activity-backed Operations. +- [Bidirectional linking](/nexus/execution-debugging#bi-directional-linking) for what the Nexus-aware Client gives you. +- [.NET Nexus feature guide](/develop/dotnet/nexus/feature-guide) +::: diff --git a/docs/develop/go/nexus/activity-backed-operations.mdx b/docs/develop/go/nexus/activity-backed-operations.mdx new file mode 100644 index 0000000000..17ce50eed7 --- /dev/null +++ b/docs/develop/go/nexus/activity-backed-operations.mdx @@ -0,0 +1,178 @@ +--- +id: activity-backed-operations +title: Activity-backed Nexus Operations - Go SDK +sidebar_label: Activity-backed Operations +description: How to back a Nexus Operation with a Standalone Activity using TemporalOperationHandler in the Go SDK. +toc_max_heading_level: 4 +slug: /develop/go/nexus/activity-backed-operations +tags: + - Nexus + - Go SDK + - Activities +--- + +:::caution + +Activity-backed Nexus Operations are pre-release and built on the [Temporal Operation Handler](/nexus/temporal-operation-handler). +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A Nexus Operation can be backed by a [Standalone Activity](/standalone-activity) as well as a Workflow. +Starting the Operation starts an Activity Execution that has no parent Workflow, and the Operation completes when that Activity returns. + +For the conceptual model, see [Nexus Standalone Activity](/nexus/standalone-activity). +This page shows how to implement Activity-backed Operations with `TemporalOperationHandler` in the Go SDK. + +## When to use an Activity-backed Operation + +The shape fits work that is one durable step sitting behind a team boundary. +You get all of the following without building any of it yourself: + +- **Durability per call.** Retries on the policy you set, timeouts you control, and a record of every attempt including the last error. +- **Deduplicated starts.** A stable Activity Id means a retried or redelivered request targets the same Activity Execution instead of starting a second one, so you do not need a separate store of processed Ids. +- **Protection from a failing dependency.** Repeated retryable failures trip the [circuit breaker](/nexus/operations#circuit-breaking) rather than piling retries onto a system that is already struggling. +- **A boundary between teams.** Callers get scoped access to named Operations, not write access to your Namespace, and they never see your Task Queues, your Workers, or your implementation. +- **Room to change your mind.** As long as the contract holds you can rewrite what runs behind an Operation, including replacing the Activity with a Workflow later, and no caller changes. + +Either calling style works: a caller Workflow can invoke the Operation as one step of a larger process, or a Client can start it directly as a [Standalone Nexus Operation](/standalone-nexus-operation) with no Workflow on either side. + +A sampling of customer use cases this was built to address follows. + +### Durable webhook and event processing without running a queue + +A service ingests webhooks from third-party providers — payment events, repository pushes, delivery receipts, CRM change notifications. +Each one needs to trigger a single durable task: update a downstream system, kick off a notification, index into search, sync into a warehouse. + +Providers retry aggressively if you do not return `200` within seconds, so the receiver has to accept fast and do the work elsewhere. +The usual way to get there is to assemble it yourself: a queue, a consumer fleet, hand-written retry and dead-letter handling, and a separate store holding processed event Ids for deduplication. +That is a lot of infrastructure whose only job is to run one function reliably. + +An Activity-backed Operation replaces the whole assembly. +The receiver starts the Operation and returns `200` immediately; Temporal owns delivery from that point. +The provider's own event Id becomes the Activity Id, so a redelivered webhook targets the same Activity Execution instead of starting a second one, and the deduplication store disappears. +Retries, backoff, and the record of every attempt come from the Activity. +When a downstream dependency starts failing, the [circuit breaker](/nexus/operations#circuit-breaking) trips rather than letting retries pile up against it. + +The team that owns the receiver is usually not the team that owns the processing, and the Nexus Endpoint is where that boundary lands: scoped access to specific Operations rather than write access to a Namespace. + +### Sandboxing a tool call + +An agent needs to call a tool — an MCP tool, an internal API, something that executes untrusted input. +You want that call to run somewhere with its own credentials, its own network reach, and its own blast radius, not inside the process orchestrating the agent. + +An Activity-backed Operation puts a Namespace boundary between the two. +The tool runs on Workers in the Namespace that owns it, under that Namespace's credentials, and the caller only ever sees the Operation contract. +The call is still durable and retried, and it is still traceable end to end, but the caller cannot reach past the contract into the environment the tool runs in. +See [Build AI applications with Temporal](/with-ai) for how this fits alongside the rest of the agent stack. + +### A durable front door to another system + +Any system you call can fail, time out, or be down when you need it — a third-party API, a legacy internal service, an unreliable piece of infrastructure. +Wrapping that call in an Activity-backed Operation makes every call against it durable: retried on your policy, bounded by your timeouts, and recorded whether it succeeded or not. + +Written once and published as a Nexus Service, it becomes a connector that every team calls instead of each writing its own integration. +The team that owns it can fix a bug or change the implementation behind the contract, without a coordinated rollout across every consumer. + +### Related patterns + +The same shape fits anything that is an external trigger, one durable step, and a team boundary. + +- **Asynchronous user actions from a backend-for-frontend.** A user clicks "export my data" or "revoke my sessions"; the BFF starts the Operation and hands the client a handle to poll. +- **Consumer offload.** A Kafka or event-stream consumer starts an Operation, commits its offset, and lets Temporal own durability from there. The event Id is the deduplication key. +- **Platform actions triggered by CI/CD.** A pipeline step requests a compliance scan, a canary step, or a credential rotation. The platform team owns the handler; consumer teams get scoped Endpoint access. +- **Scheduled platform tasks.** A scheduler fires an Operation and a shared platform team's Workers run the task. + +## How it works + +Use `TemporalOperationHandler` and call `startActivity` on the injected Nexus-aware Client. +The handler returns an async result carrying an activity-execution Operation token, and the server delivers the Activity's result to the caller through the Nexus completion callback when the Activity finishes. + +```go +var greetOp = temporalnexus.MustNewTemporalOperation( + temporalnexus.TemporalOperationOptions[GreetingInput, GreetingOutput]{ + Name: "greet", + Start: func( + ctx context.Context, + nc temporalnexus.NexusClient, + input GreetingInput, + opts temporalnexus.StartTemporalOperationOptions, + ) (temporalnexus.TemporalOperationResult[GreetingOutput], error) { + return temporalnexus.StartActivity(ctx, nc, client.StartActivityOptions{ + ID: "greet-" + opts.RequestID, + StartToCloseTimeout: 10 * time.Second, + }, Greet, input) + }, + }) +``` + + +You write the Activity the same way whichever side calls it. +The same Activity Function can be executed by a Workflow and started behind a Nexus Operation with no code changes — nothing in its definition is Nexus-specific. +What differs is how it is started, not what it is. + +```go +func Greet(ctx context.Context, input GreetingInput) (GreetingOutput, error) { + return GreetingOutput{Message: "Hello, " + input.Name}, nil +} +``` + + +### Required options + +Starting an Activity this way needs values that a Workflow-called Activity does not. + +- **An Activity Id**, unique within the Namespace. There is no parent Workflow to scope it. +- **A timeout.** At least one of start-to-close or schedule-to-close. + +The Task Queue is optional. It defaults to the Task Queue the Operation is running on, which is what the samples above rely on. Set it explicitly to run the Activity on its own Worker fleet rather than the one the Endpoint targets. + +Deriving the Id from the Nexus request Id makes the start idempotent. +The server retries a Nexus start request using the same request Id, so each retry targets the same Activity Id rather than starting a second Activity. + +Setting the Activity Id conflict policy to use-existing attaches to an already-running Activity with that Id instead of failing. +Combined with an Id derived from the Operation *input* rather than the request Id, this lets several Nexus Operations share one Activity Execution and all receive its result. + +### Register the Worker + +Register the Activity implementations and the Nexus Service implementation on a Worker polling the Endpoint's target Task Queue. +There is no Workflow implementation to register. + +```go +w := worker.New(c, TaskQueueName, worker.Options{}) +w.RegisterActivity(Greet) + +service := nexus.NewService("greeting") +if err := service.Register(greetOp); err != nil { + log.Fatal(err) +} +w.RegisterNexusService(service) +``` + + +## Cancellation + +Worth remembering, because it is the one behavioral difference from a Workflow-backed Operation that surprises people. + +A Workflow is interrupted by a cancellation request. An Activity is not: the Worker only learns about it on the next heartbeat, so an Activity that never heartbeats runs until it completes or hits its timeout, no matter how many cancellation requests the caller sends. + +Nothing about this is Nexus-specific. See [Activity cancellation](/activity-execution#cancellation) for how to heartbeat, what to do with the resulting cancellation exception, and why a heartbeat timeout matters. For a short Activity that finishes well inside its timeout and doesn't have the risk of hanging, however, a heartbeat is not needed. + +## Choose between an Activity and a Workflow + +Back an Operation with an **Activity** when the work is a single step: one external call, one computation, one notification. +You get retries, timeouts, and a durable record of the step, without an Event History tracking orchestration you are not doing. + +Back an Operation with a **Workflow** when the work has more than one step, needs to wait for something, needs to receive [messages](/sending-messages), or needs durable intermediate state. +For example, an approval that blocks for a human decision is a Workflow, not an Activity. + +Sample code: `{code not yet live}` + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for the conceptual model. +- [Temporal Operation Handler](/develop/go/nexus/temporal-operation-handler) for the handler type and the Nexus-aware Client. +- [Standalone Activity](/standalone-activity) for the underlying concept. +- [Standalone Nexus Operation](/standalone-nexus-operation) for starting Operations without a caller Workflow. + +::: diff --git a/docs/develop/go/nexus/client-code-generator.mdx b/docs/develop/go/nexus/client-code-generator.mdx new file mode 100644 index 0000000000..144772527d --- /dev/null +++ b/docs/develop/go/nexus/client-code-generator.mdx @@ -0,0 +1,362 @@ +--- +id: client-code-generator +title: Nexus Client Code Generator - Go SDK +sidebar_label: Client Code Generator +description: How to install nexgen and generate typed Nexus models and Service definitions for Go. +toc_max_heading_level: 4 +slug: /develop/go/nexus/client-code-generator +tags: + - Nexus + - Go SDK +--- + +A [Nexus Service](/nexus/services) is a contract meant to be shared across team boundaries. +Those teams often work in different languages, so the same request and response types get hand-written for each SDK. +Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler. + +The **[Nexus Client Code Generator](https://github.com/temporalio/nexgen)** removes those copies. +You describe your types and [Nexus Operations](/nexus/operations) once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. +The generator is a command-line tool named `nexgen`, distributed from the [temporalio/nexgen](https://github.com/temporalio/nexgen) repository. + +For a short overview of what the generator produces and why, see [Nexus Client Code Generator](/nexus/client-code-generator). +This page covers installation, schema authoring, and generating and using Go output. + +:::caution + +`nexgen` is pre-release software and may not retain backwards compatibility with previous versions of the tool. +It is not yet published to any package registry, so you build it from source as described in [Install the generator](#install-the-generator). + +::: + +## What the generator produces + +The generator produces a client library in Go, Java, Python, or TypeScript for the inputs and outputs of your Nexus Operations. The generated types check values against the contract as they are sent and received, so a violation surfaces as an error rather than as bad data. + +That client library contains three things: + +- **A typed model.** An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema. +- **A runtime validator.** One validator per type, applied when a value is parsed off the wire and again when it is serialized onto it. See [Validation guarantees](#validation-guarantees) for more. +- **A [Nexus Service Contract](/glossary#nexus-service-contract) definition**, for a file that declares Services. These are the Service and Operation declarations you register on a Worker and call from a caller Workflow. A pure JSON Schema file declares none, so it produces only the models and their validators. + +Additionally, constraint failures are aggregated into a single native error listing every violation, each naming the offending field and the bound it broke. +A handler maps that error to a `BAD_REQUEST` [Nexus error](/nexus/error-handling), so a malformed request tells the caller everything that was wrong with it in one response. + +The supported schema subset is deliberately strict. +Anything ambiguous, or anything that cannot be expressed identically in all generated languages, is rejected when you run the generator with a diagnostic explaining how to express it correctly. +The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another. + +## Supported languages + +This page covers the Go output from `nexgen`. + +## Definition files + +Types are modeled with [JSON Schema 2020-12](https://json-schema.org/draft/2020-12). +A definition file is one of two kinds, decided by what sits at its root. +A file is one or the other, never both. + +**Pure JSON Schema.** The root of the document is itself a type, and reusable types live under `$defs`. +Use this when you only need data models shared across languages, with no Service or Operation declarations. + +**Nexus document.** Add a root `nexusrpc: "1.0.0"` marker to enable a `services` section. +The root becomes an envelope: Services and their Operations sit at the top level, and your types live under `$defs`. +Only this kind can declare a Service. + +The two kinds compose across files, so a contract is not limited to one of them. +A Service contract is often a Nexus document declaring the Services and Operations, plus pure JSON Schema files holding the types it `$ref`s by relative path. +The [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) closure described below is built that way. + +The examples on this page use [`samples/schemas/chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml) from the repository, abbreviated here: + +```yaml +nexusrpc: '1.0.0' +$schema: https://json-schema.org/draft/2020-12/schema +services: + ChatService: + fqn: example.chat.v1.ChatService + description: Send messages and look up rooms. + operations: + sendMessage: + description: Post a message to a room. + input: { $ref: '#/$defs/SendMessageInput' } + output: { $ref: '#/$defs/SendMessageOutput' } + getRoom: + description: Look up a room by id. + input: + type: object + additionalProperties: false + properties: + roomId: { type: string } + required: [roomId] + output: { $ref: '#/$defs/Room' } + ping: + description: Liveness probe. +$defs: + SendMessageInput: + type: object + additionalProperties: false + properties: + roomId: { type: string } + message: { $ref: '#/$defs/Message' } + required: [roomId, message] + SendMessageOutput: + type: object + additionalProperties: false + properties: + messageId: { type: string } + required: [messageId] +``` + +See [Definition files](https://github.com/temporalio/nexgen#definition-files) in the generator's README for details on that file. + +### How names are derived + +You write two kinds of name in the schema: one for the Service, and one for each Operation. In the sample above they are `ChatService` and `sendMessage`: + +```yaml +services: + ChatService: # the Service name + operations: + sendMessage: # the Operation name +``` + +Those are the only names you write. From each one the generator produces two more: a wire name and a name in your code. You declare neither of them. + +Give each name the casing that matches what it becomes: + +- A **Service** name is PascalCase: `ChatService`. A Service becomes a type in the generated code, and types are PascalCase. +- An **Operation** name is camelCase: `sendMessage`. An Operation becomes a method on that type, and the generator cases it like any other member. + +The first letter is the part the generator enforces: a Service has to start uppercase and an Operation lowercase. Both must begin with a letter and contain only letters and digits. + +``` +service name `chatService` must match `^[A-Z][a-zA-Z\d]+$` (start uppercase, then +letters/digits); set the wire name via `fqn` if it must differ +``` + +:::note Overriding the wire name + +The `fqn` in that error — a fully qualified name — is optional, and you can skip it to start. + +It lets a Service or Operation carry a wire name of your choosing rather than the one the generator derives from the name you wrote. Because it never becomes a code identifier, it accepts characters a name cannot: that is how a Service gets a wire name of `example.chat.v1.ChatService`, or an Operation one of `poll-messages`. Wire names are covered just below. + +Use `fqn` when you need to match a contract that is already published, or when you want a versioned, namespaced wire name. Otherwise leave it out. + +::: + +**The wire name** is the string the caller and the handler exchange, and what appears in Event History and the Temporal UI. Neither side types it — both take it from the generated code. + +Unless you override it, the wire name is the name from the definition file converted to PascalCase. In the sample that gives the Service a wire name of **`ChatService`** and the `sendMessage` Operation a wire name of **`SendMessage`**. + +In the sample above the Service does override it using `fqn`, so the Service wire name becomes **`example.chat.v1.ChatService`**. + +**The name in your code** is what you call. The generator recases the name you wrote to match the conventions of the language it is generating for. With no override in play, the two derived names line up like this: + +| You write | Wire name | Java | Go | Python | TypeScript | +| --- | --- | --- | --- | --- | --- | +| `ChatService` | `ChatService` | `ChatService` | `ChatService` | `ChatService` | `chatService` | +| `sendMessage` | `SendMessage` | `sendMessage` | `SendMessage` | `send_message` | `sendMessage` | + +Operations become camelCase methods in Java and TypeScript, snake_case in Python, and PascalCase in Go. + +An Operation's `input` and `output` are each optional. +The `ping` Operation above declares neither, which generates an Operation that takes and returns nothing. +When present, each must be an object type, so that a field can be added later without breaking the wire format. + +The repository holds four example definitions under [`samples/schemas/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas): +[`chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml), +the feature-diverse [`showcase.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/showcase.nexusrpc.yaml), +the pure-schema [`temporal.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/temporal.yaml), +and a multi-file closure under [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) showing how types split across files resolve through `$ref`. +The `kb/` closure starts at [`kb.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/kb/kb.nexusrpc.yaml) and pulls in types from its [`content/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/content) and [`tree/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/tree) subdirectories. + +## Install the generator + +Build the `nexgen` binary from source with Cargo, the Rust build tool: + +```bash +git clone https://github.com/temporalio/nexgen.git +cd nexgen +cargo build --release +``` + +The binary lands at `target/release/nexgen`. +Confirm it works and check which targets your build supports: + +```bash +./target/release/nexgen --version +./target/release/nexgen --help +``` + +## Generate code + +Every language uses the same shape: `nexgen ... --output `. +Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags. + +:::note + +The output directory name becomes the generated package or module name. +Name it after your domain, such as `chat`, not after the language. +Pointing `--output` at a directory named `go` produces `package go`, which is not valid Go. + +::: + +### Go + +```bash +nexgen go samples/schemas/chat.nexusrpc.yaml --output ./chat +``` + +Place the output directory inside your Go module. +The package name is the directory name, so the example above generates `package chat` in `./chat/chat.go` alongside `./chat/definitions.go`. + +## Dates, times, and durations + +TypeScript's `--date-time-types` is the only place you choose how a date or time is represented. Elsewhere the generator decides: Java uses `java.time`, Python `datetime` and `timedelta`, and Go `time.Time` and `time.Duration`. Two cases hand you the wire string to work with instead of a date type — `format: time` in Java, and every date and time format under TypeScript's default `string` mode. + +Whichever type you get, every language writes the same bytes. Dates and times use [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339), which is a profile of [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601). ISO 8601 permits many optional spellings of the same instant, and RFC 3339 narrows them to one so two systems cannot read a timestamp differently. RFC 3339 specifies timestamps rather than durations, so durations follow ISO 8601. + +## How validation works + +Validation is the one behavior the generated types add, so a call can fail with a contract violation that hand-written types would not have caught. Only the wiring of that validation differs between languages. + +| SDK | How validation reaches the wire | Extra step | +| ---------- | ----------------------------------------------------------- | ---------- | +| Go | Generated `MarshalJSON` and `UnmarshalJSON` on each model | None | +| Java | Generated Jackson serializer and deserializer on each model | None | +| Python | Pydantic model validation | [Use the Pydantic data converter](/develop/python/data-handling/data-conversion#use-pydantic-models) | +| TypeScript | Generated mapper classes | Call the mapper yourself | + +In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. +TypeScript requires an explicit call, covered in [Validate payloads in TypeScript](/develop/typescript/nexus/client-code-generator#validate-payloads-in-typescript). + +### Validation guarantees + +The two directions do not check the same things. + +**Parsing a value off the wire** enforces required fields and every value constraint, aggregating all violations into one error. This is the direction that protects a handler from a malformed request. + +**Serializing a value onto the wire** enforces value constraints — lengths, bounds, counts, patterns. It does not report a required field you left unset. The field is omitted from the payload and the peer rejects it, so the failure surfaces as a `BAD_REQUEST` from the other side rather than as a local error at the point you built the object. + +For code that catches and logs a violation, see the per-language examples in [Use the generated code](#use-the-generated-code). + +## Use the generated code + +**Whether the code is generated or written by hand, you use it the same way.** It is a Service definition and a set of types: register it on a Worker, and call it from a caller Workflow exactly as described in your SDK's Nexus guide. The one difference is that generated types validate themselves, so a call can fail with a contract violation for you to catch. + +Each example below registers a handler, calls the Operation from a caller Workflow, and catches a validation failure. + +### Go + +The generated `ChatService` value carries the Service name and one typed Operation reference per Operation. +Register handlers on a Worker: + +```go +service := nexus.NewService(chat.ChatService.ServiceName) + +sendMessage := nexus.NewSyncOperation(chat.ChatService.SendMessage.Name(), + func(ctx context.Context, input chat.SendMessageInput, _ nexus.StartOperationOptions) (chat.SendMessageOutput, error) { + return chat.SendMessageOutput{MessageId: store(input)}, nil + }) + +if err := service.Register(sendMessage); err != nil { + return err +} +w.RegisterNexusService(service) +``` + +Call it from a caller Workflow, passing the generated Operation reference so the SDK type-checks the request and response. A payload that violates the contract fails at the call, so that is where you catch it: + +```go +client := workflow.NewNexusClient("chat-endpoint", chat.ChatService.ServiceName) + +var output chat.SendMessageOutput +err := client.ExecuteOperation( + ctx, + chat.ChatService.SendMessage, + chat.SendMessageInput{RoomId: "r1", Message: chat.Message{Kind: "text", Body: "hi"}}, + workflow.NexusOperationOptions{}, +).Get(ctx, &output) + +if err != nil { + var validationErr *chat.ValidationError + if errors.As(err, &validationErr) { + for _, v := range validationErr.Violations { + logger.Error("contract violation", "path", v.Path, "reason", v.Reason) + } + } else { + logger.Error("Nexus call failed", "error", err) + } + return err +} +``` + +`ValidationError` carries every violation as a `Violation` with a `Path` and a `Reason`. Reach it with `errors.As` rather than a type assertion, because the error arrives wrapped by the JSON encoder. It is generated into each package, so a consumer of two generated Services needs one `errors.As` per package. + +## Regenerate after a contract change + +Generated files carry a `DO NOT EDIT` header and are replaced wholesale on the next run. There is no merge step, so anything you add to them is lost. + +Two habits make this safe: + +- **Commit generated code and regenerate as its own commit.** The diff then shows exactly what the contract change did to each language. +- **Fix names in the schema, not the output.** When a generated identifier is wrong for your language, set a per-language override in the contract so the fix survives regeneration. See [Naming and overrides](https://github.com/temporalio/nexgen#naming--overrides) for the available keys. + +If you generate into the wrong directory, delete what landed there and run the generator again with corrected flags. Do not edit the `package` or module declaration to match where the files ended up as the next run will overwrite it. + +## Schema defaults + +A property can declare a `default`, which makes it optional for a caller to supply: + +```yaml +sampleValue: + type: integer + default: 0 +``` + +A caller that leaves `sampleValue` unset sends a payload without the field, and the receiver reads `0`. The default is never written into the payload, so an omitted field stays omitted rather than being filled in before it is sent. + +:::caution Changing a default is a breaking change + +The default lives in the generated code, not in the payload. Temporal replays a Workflow by re-reading the payloads already recorded in its [Event History](/encyclopedia/event-history) using whatever code the Worker is running now, so changing a default changes what those recorded payloads mean. + +If the value affects which commands the Workflow produces, replaying an in-flight Workflow fails with a [non-determinism error](/troubleshooting/execution-failures#non-determinism-error) — the [deterministic constraints](/workflow-definition#deterministic-constraints) that govern any change to Workflow code apply here too. If it does not affect commands, nothing fails and the behavior changes silently, which is harder to catch. + +Treat a default as part of the contract. Adding a replacement field is not sufficient on its own: old payloads omit the new field too, so replay reads that field's default and can still diverge. Any change to how existing payloads are interpreted needs a [Workflow versioning](/workflow-definition#workflow-versioning) plan that keeps in-flight Executions on their original behavior. + +::: + +Because the field is optional, Go and Java give you two members side by side, so nothing depends on remembering that a default exists: + +- The field itself, which is empty when the caller omitted it — `getSampleValue()` returns `null` in Java, and `SampleValue` is a `nil *int64` in Go. +- An accessor named after it that substitutes the default — `getSampleValueOrDefault()` and `SampleValueOrDefault()`. + +Use the first when you need to know whether the caller supplied a value, and the second when you just want a number. + +TypeScript has no accessor. `sampleValue` is `undefined` when unset, and the generator exports a `DEFAULT_SAMPLE_VALUE` constant you apply yourself: `sampleValue ?? DEFAULT_SAMPLE_VALUE`. + +Python has neither. Pydantic applies defaults when the model is constructed, so `sample_value` always holds a value and an omitted field reads the same as one explicitly set to `0`. + +## Supported schema features + +The generator implements a curated subset of [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) chosen so that every accepted construct lowers identically into all generated languages. + +Fully supported: `properties`, `required`, `default`, `minProperties` and `maxProperties`, `dependentRequired`, string and numeric bounds, `items`, `minItems` and `maxItems`, `minContains` and `maxContains`, `allOf`, the recognized nullable pattern `oneOf: [{type: T}, {type: "null"}]`, and the `title`, `description`, and `deprecated` annotations. + +Partially supported: `type` (single-string form only), `additionalProperties`, `propertyNames`, `const` and `enum` (scalars only), `format`, `pattern` (a portable RE2-safe subset), `multipleOf`, `contentEncoding`, `uniqueItems`, `contains`, `oneOf` (branches must be separable by a decidable selector), and `$ref` with `$defs` (local files only). + +Deliberately rejected, because they have no coherent typed lowering across all generated languages: `anyOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `contentMediaType`, and `contentSchema`. + +For the current per-keyword support table, see the [nexgen README](https://github.com/temporalio/nexgen#supported-json-schema-features). + +:::tip RESOURCES + +- [temporalio/nexgen](https://github.com/temporalio/nexgen) for the generator, its README, and the example schemas. +- [Nexus Services](/nexus/services) for the Service contract concept. +- Nexus feature guides for registering Services and calling Operations: + [Go](/develop/go/nexus/feature-guide) | + [Java](/develop/java/nexus/feature-guide) | + [Python](/develop/python/nexus/feature-guide) | + [TypeScript](/develop/typescript/nexus/feature-guide) + +::: diff --git a/docs/develop/go/nexus/temporal-operation-handler.mdx b/docs/develop/go/nexus/temporal-operation-handler.mdx new file mode 100644 index 0000000000..d0e9c68c29 --- /dev/null +++ b/docs/develop/go/nexus/temporal-operation-handler.mdx @@ -0,0 +1,235 @@ +--- +id: temporal-operation-handler +title: Temporal Operation Handler - Go SDK +sidebar_label: Temporal Operation Handler +description: How to implement Nexus Operations with TemporalOperationHandler in the Go SDK. +toc_max_heading_level: 4 +slug: /develop/go/nexus/temporal-operation-handler +tags: + - Nexus + - Go SDK +--- + +:::caution + +The Temporal Operation Handler is pre-release. +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A [Nexus Service](/nexus/services) publishes Operations that other teams call across [Namespace](/namespaces) boundaries. +`TemporalOperationHandler` is how you implement those Operations. + +For the conceptual model, see [Temporal Operation Handler](/nexus/temporal-operation-handler). +This page shows how to write handlers in the Go SDK, migrate from earlier APIs, and compose Workflow, Update, Signal, and Activity backings. + +## What you can do with it + +**Back an Operation with whichever primitive fits the work.** A multi-step process is a Workflow. A single durable step is an [Activity](/activities), with no Workflow wrapped around it. A change to something already running is an Update. The caller sees the same Operation contract either way, and you can change your choice later without touching callers. + +**Combine messaging and a backing in one handler.** A handler can Signal a running Workflow to unblock it and then return a different Execution's result for the caller to await. These are not separate handler types you pick between; they compose inside one start handler. + +**Get observability across the Namespace boundary without wiring it.** The Client handed to your handler propagates [bidirectional links](/nexus/execution-debugging#bi-directional-linking) and request Ids on every call it makes. The caller-side and handler-side Executions are connected in the UI and in [Event History](/encyclopedia/event-history), so a single trace crosses the boundary between two teams' Namespaces. + +**Stay idempotent through retries.** The server retries Nexus start requests, and the request Id travels with them. Deriving the backing Execution's Id from it means a retry targets the same Execution rather than starting a second one. + +**Cancel through the same handler.** The Operation token records which kind of Execution backs the Operation, so a cancellation request reaches the right place. The default behavior is usually what you want, and each kind can be overridden when it is not. + +**Grow a handler without rewriting it.** An Operation that starts out completing inline can later gain an async backing, or send a Signal, without changing handler type or breaking its contract. + +## The Nexus-aware Client + +A `TemporalOperationHandler` start handler receives three things: a context, a Client, and the Operation input. + +The Client is what makes the linking automatic, so prefer it over constructing your own inside a handler. +Reaching for your own Client still works, but messages sent that way are not connected back to the caller. + +The Client exposes two kinds of call, and the distinction shapes how you write the handler. +The examples below use the Go SDK APIs. + +**Async backings — at most one per Operation invocation.** These determine what the Operation *is*, and their result is delivered to the caller through the Nexus completion callback when the underlying Execution finishes. + +- Start a Workflow — the Operation completes when the Workflow returns +- Update a Workflow — the Operation completes when the Update completes +- Start an Activity — the Operation completes when the Activity returns; see [Nexus Standalone Activity](/develop/go/nexus/activity-backed-operations) + +**Sync messaging — as many as you need.** Reach these through the underlying Temporal Client that the Nexus-aware Client exposes. +They take effect during the handler call, still get link propagation, and do not require an async backing. + +- Signal — delivered during the handler call to a Workflow that is already running +- Signal-with-Start — delivers the Signal, starting the Workflow first if it is not already running + +A handler that only sends messages returns a synchronous result, and the Operation completes immediately. + +## Write an Operation handler + +The examples below use a Nexus Service with a `startGreeting` Operation backed by a Workflow, an `updateShippingAddress` Operation backed by an Update, a `cancelOrder` Operation that sends a Signal, and a `greet` Operation backed by an Activity. + +### Back an Operation with a Workflow + +Call `startWorkflow` on the Client and return its result. The Operation completes when the Workflow returns, delivering the Workflow's return value to the caller. + +```go +op := temporalnexus.MustNewTemporalOperation( + temporalnexus.TemporalOperationOptions[GreetingInput, GreetingOutput]{ + Name: "startGreeting", + Start: func( + ctx context.Context, + nc temporalnexus.NexusClient, + input GreetingInput, + _ temporalnexus.StartTemporalOperationOptions, + ) (temporalnexus.TemporalOperationResult[GreetingOutput], error) { + return temporalnexus.StartWorkflow(ctx, nc, + client.StartWorkflowOptions{ID: "greeting-" + input.Name}, + GreetingWorkflow, input) + }, + }) +``` + + +Go exposes the start calls as package-level functions taking the Client, rather than as methods on it, because Go does not allow generic methods on a non-generic struct. + +### Back an Operation with an Update + +Back an Operation with an Update when it changes something already running. The target Workflow has to exist already, and the Operation completes when the Update completes. + +```go +op := temporalnexus.MustNewTemporalOperation( + temporalnexus.TemporalOperationOptions[UpdateAddressInput, AddressOutput]{ + Name: "updateShippingAddress", + Start: func( + ctx context.Context, + nc temporalnexus.NexusClient, + input UpdateAddressInput, + _ temporalnexus.StartTemporalOperationOptions, + ) (temporalnexus.TemporalOperationResult[AddressOutput], error) { + return temporalnexus.StartUpdateWorkflow[AddressOutput](ctx, nc, + client.UpdateWorkflowOptions{ + WorkflowID: "order-" + input.OrderID, + UpdateName: "updateShippingAddress", + Args: []any{input}, + WaitForStage: client.WorkflowUpdateStageAccepted, + }) + }, + }) +``` + + +Three constraints apply to Update-backed Operations, and the first two fail the Operation rather than degrading: + +- **Only the accepted stage is supported.** A Nexus Operation can only back an asynchronous Update, so the wait-for-stage must be "accepted". +- **The Update Id defaults to the Nexus request Id.** Leaving it unset is what you usually want: a retried start request carries the same request Id, so it targets the same Update rather than running a second one. + +The result is async in the normal case, carrying an update-workflow Operation token. If the Update has already completed by the time it is accepted — a retried request with the same Update Id, or an Update that completes immediately — you get a synchronous result instead. + +### Send a Signal from an Operation + +Reach the Workflow Client through the injected Client, send the message, and return a synchronous result. The Operation completes during the handler call, and the Signal is linked back to the caller. + +```go +op := temporalnexus.MustNewTemporalOperation( + temporalnexus.TemporalOperationOptions[CancelOrderInput, nexus.NoValue]{ + Name: "cancelOrder", + Start: func( + ctx context.Context, + nc temporalnexus.NexusClient, + input CancelOrderInput, + _ temporalnexus.StartTemporalOperationOptions, + ) (temporalnexus.TemporalOperationResult[nexus.NoValue], error) { + err := nc.GetWorkflowClient().SignalWorkflow( + ctx, "order-"+input.OrderID, "", "requestCancellation", input) + if err != nil { + return temporalnexus.TemporalOperationResult[nexus.NoValue]{}, err + } + return temporalnexus.NewSyncResult[nexus.NoValue](nil), nil + }, + }) +``` + + +The same Client also offers Signal-with-Start, and a handler may send several messages before returning. + +### Back an Operation with an Activity + +Call `startActivity` when the work is a single durable step. The Activity runs with no parent Workflow, so the options require an Activity Id and at least one timeout. See [Nexus Standalone Activity](/develop/go/nexus/activity-backed-operations). + +```go +op := temporalnexus.MustNewTemporalOperation( + temporalnexus.TemporalOperationOptions[GreetingInput, GreetingOutput]{ + Name: "greet", + Start: func( + ctx context.Context, + nc temporalnexus.NexusClient, + input GreetingInput, + opts temporalnexus.StartTemporalOperationOptions, + ) (temporalnexus.TemporalOperationResult[GreetingOutput], error) { + return temporalnexus.StartActivity(ctx, nc, client.StartActivityOptions{ + ID: "greet-" + opts.RequestID, + TaskQueue: TaskQueueName, + StartToCloseTimeout: 10 * time.Second, + }, Greet, input) + }, + }) +``` + + +## Coming from the earlier handler APIs + +Skip this section if you are new to Nexus. + +Earlier SDK versions had a separate helper per pattern. Existing handlers will keep working, there is no forced migration. + +Operations already in progress are not a concern either. If you need to cancel one, for example, a Workflow-backed Operation started by one of the earlier APIs is cancelled by `TemporalOperationHandler` just as it would have been before. + +| If you used | Use instead | +| --- | --- | +| The Workflow-run helper (`WorkflowRunOperation`, `NewWorkflowRunOperation`, `@workflow_run_operation`) | `TemporalOperationHandler` with a Workflow backing | +| The synchronous handler (`OperationHandler.sync`, `nexus.NewSyncOperation`, `@sync_operation`) | `TemporalOperationHandler` returning a sync result | +| A Workflow wrapping a single Activity | `TemporalOperationHandler` with an Activity backing | +| A Temporal Client fetched inside a handler | The Client injected into the start handler | + +Two things improve when you migrate. Messages and Executions get [bidirectional linking](/nexus/execution-debugging#bi-directional-linking), which hand-fetched Clients do not produce. And one handler type covers every case, so an Operation can change what backs it without changing shape. + +### Migrating a Workflow-backed Operation + +The earlier helper reached the Client through the Operation context and returned a Workflow handle or method reference, rather than being handed a Client and returning an Operation result: + +```go +op := temporalnexus.NewWorkflowRunOperation( + "startGreeting", + GreetingWorkflow, + func(ctx context.Context, input GreetingInput, opts nexus.StartOperationOptions) (client.StartWorkflowOptions, error) { + return client.StartWorkflowOptions{ + ID: "greeting-" + input.Name, + }, nil + }) +``` + + +Replace it with [Back an Operation with a Workflow](#back-an-operation-with-a-workflow). + +### Migrating a synchronous Operation + +A messaging Operation used to be a synchronous handler that fetched its own Client, which is why those messages produced no links: + +```go +// nexus.NewSyncOperation comes from the separate nexus-rpc SDK, not from temporalnexus. +op := nexus.NewSyncOperation("cancelOrder", + func(ctx context.Context, input CancelOrderInput, o nexus.StartOperationOptions) (nexus.NoValue, error) { + c := temporalnexus.GetClient(ctx) + return nil, c.SignalWorkflow(ctx, "order-"+input.OrderID, "", "requestCancellation", input) + }) +``` + + +Replace it with [Send a Signal from an Operation](#send-a-signal-from-an-operation). + +:::tip RESOURCES + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the conceptual model. +- [Nexus Services](/nexus/services) and [Nexus Operations](/nexus/operations) for the underlying concepts. +- [Nexus Client Code Generator](/develop/go/nexus/client-code-generator) to generate Service contracts and typed models from one schema. +- [Activity-backed Nexus Operations](/develop/go/nexus/activity-backed-operations) for Activity-backed Operations. +- [Bidirectional linking](/nexus/execution-debugging#bi-directional-linking) for what the Nexus-aware Client gives you. +- [Go Nexus feature guide](/develop/go/nexus/feature-guide) +::: diff --git a/docs/develop/java/nexus/activity-backed-operations.mdx b/docs/develop/java/nexus/activity-backed-operations.mdx new file mode 100644 index 0000000000..5ba1beeb4b --- /dev/null +++ b/docs/develop/java/nexus/activity-backed-operations.mdx @@ -0,0 +1,178 @@ +--- +id: activity-backed-operations +title: Activity-backed Nexus Operations - Java SDK +sidebar_label: Activity-backed Operations +description: How to back a Nexus Operation with a Standalone Activity using TemporalOperationHandler in the Java SDK. +toc_max_heading_level: 4 +slug: /develop/java/nexus/activity-backed-operations +tags: + - Nexus + - Java SDK + - Activities +--- + +:::caution + +Activity-backed Nexus Operations are pre-release and built on the [Temporal Operation Handler](/nexus/temporal-operation-handler). +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A Nexus Operation can be backed by a [Standalone Activity](/standalone-activity) as well as a Workflow. +Starting the Operation starts an Activity Execution that has no parent Workflow, and the Operation completes when that Activity returns. + +For the conceptual model, see [Nexus Standalone Activity](/nexus/standalone-activity). +This page shows how to implement Activity-backed Operations with `TemporalOperationHandler` in the Java SDK. + +## When to use an Activity-backed Operation + +The shape fits work that is one durable step sitting behind a team boundary. +You get all of the following without building any of it yourself: + +- **Durability per call.** Retries on the policy you set, timeouts you control, and a record of every attempt including the last error. +- **Deduplicated starts.** A stable Activity Id means a retried or redelivered request targets the same Activity Execution instead of starting a second one, so you do not need a separate store of processed Ids. +- **Protection from a failing dependency.** Repeated retryable failures trip the [circuit breaker](/nexus/operations#circuit-breaking) rather than piling retries onto a system that is already struggling. +- **A boundary between teams.** Callers get scoped access to named Operations, not write access to your Namespace, and they never see your Task Queues, your Workers, or your implementation. +- **Room to change your mind.** As long as the contract holds you can rewrite what runs behind an Operation, including replacing the Activity with a Workflow later, and no caller changes. + +Either calling style works: a caller Workflow can invoke the Operation as one step of a larger process, or a Client can start it directly as a [Standalone Nexus Operation](/standalone-nexus-operation) with no Workflow on either side. + +A sampling of customer use cases this was built to address follows. + +### Durable webhook and event processing without running a queue + +A service ingests webhooks from third-party providers — payment events, repository pushes, delivery receipts, CRM change notifications. +Each one needs to trigger a single durable task: update a downstream system, kick off a notification, index into search, sync into a warehouse. + +Providers retry aggressively if you do not return `200` within seconds, so the receiver has to accept fast and do the work elsewhere. +The usual way to get there is to assemble it yourself: a queue, a consumer fleet, hand-written retry and dead-letter handling, and a separate store holding processed event Ids for deduplication. +That is a lot of infrastructure whose only job is to run one function reliably. + +An Activity-backed Operation replaces the whole assembly. +The receiver starts the Operation and returns `200` immediately; Temporal owns delivery from that point. +The provider's own event Id becomes the Activity Id, so a redelivered webhook targets the same Activity Execution instead of starting a second one, and the deduplication store disappears. +Retries, backoff, and the record of every attempt come from the Activity. +When a downstream dependency starts failing, the [circuit breaker](/nexus/operations#circuit-breaking) trips rather than letting retries pile up against it. + +The team that owns the receiver is usually not the team that owns the processing, and the Nexus Endpoint is where that boundary lands: scoped access to specific Operations rather than write access to a Namespace. + +### Sandboxing a tool call + +An agent needs to call a tool — an MCP tool, an internal API, something that executes untrusted input. +You want that call to run somewhere with its own credentials, its own network reach, and its own blast radius, not inside the process orchestrating the agent. + +An Activity-backed Operation puts a Namespace boundary between the two. +The tool runs on Workers in the Namespace that owns it, under that Namespace's credentials, and the caller only ever sees the Operation contract. +The call is still durable and retried, and it is still traceable end to end, but the caller cannot reach past the contract into the environment the tool runs in. +See [Build AI applications with Temporal](/with-ai) for how this fits alongside the rest of the agent stack. + +### A durable front door to another system + +Any system you call can fail, time out, or be down when you need it — a third-party API, a legacy internal service, an unreliable piece of infrastructure. +Wrapping that call in an Activity-backed Operation makes every call against it durable: retried on your policy, bounded by your timeouts, and recorded whether it succeeded or not. + +Written once and published as a Nexus Service, it becomes a connector that every team calls instead of each writing its own integration. +The team that owns it can fix a bug or change the implementation behind the contract, without a coordinated rollout across every consumer. + +### Related patterns + +The same shape fits anything that is an external trigger, one durable step, and a team boundary. + +- **Asynchronous user actions from a backend-for-frontend.** A user clicks "export my data" or "revoke my sessions"; the BFF starts the Operation and hands the client a handle to poll. +- **Consumer offload.** A Kafka or event-stream consumer starts an Operation, commits its offset, and lets Temporal own durability from there. The event Id is the deduplication key. +- **Platform actions triggered by CI/CD.** A pipeline step requests a compliance scan, a canary step, or a credential rotation. The platform team owns the handler; consumer teams get scoped Endpoint access. +- **Scheduled platform tasks.** A scheduler fires an Operation and a shared platform team's Workers run the task. + +## How it works + +Use `TemporalOperationHandler` and call `startActivity` on the injected Nexus-aware Client. +The handler returns an async result carrying an activity-execution Operation token, and the server delivers the Activity's result to the caller through the Nexus completion callback when the Activity finishes. + +```java +@ServiceImpl(service = GreetingNexusService.class) +public class GreetingNexusServiceImpl { + + @OperationImpl + public OperationHandler greet() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startActivity( + GreetingActivities.class, + GreetingActivities::greet, + input, + StartActivityOptions.newBuilder() + .setId("greet-" + context.getRequestId()) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build())); + } +} +``` + + +You write the Activity the same way whichever side calls it. +The same Activity Function can be executed by a Workflow and started behind a Nexus Operation with no code changes — nothing in its definition is Nexus-specific. +What differs is how it is started, not what it is. + +```java +@ActivityInterface +public interface GreetingActivities { + @ActivityMethod + GreetingOutput greet(GreetingInput input); +} +``` + + +### Required options + +Starting an Activity this way needs values that a Workflow-called Activity does not. + +- **An Activity Id**, unique within the Namespace. There is no parent Workflow to scope it. +- **A timeout.** At least one of start-to-close or schedule-to-close. + +The Task Queue is optional. It defaults to the Task Queue the Operation is running on, which is what the samples above rely on. Set it explicitly to run the Activity on its own Worker fleet rather than the one the Endpoint targets. + +Deriving the Id from the Nexus request Id makes the start idempotent. +The server retries a Nexus start request using the same request Id, so each retry targets the same Activity Id rather than starting a second Activity. + +Setting the Activity Id conflict policy to use-existing attaches to an already-running Activity with that Id instead of failing. +Combined with an Id derived from the Operation *input* rather than the request Id, this lets several Nexus Operations share one Activity Execution and all receive its result. + +### Register the Worker + +Register the Activity implementations and the Nexus Service implementation on a Worker polling the Endpoint's target Task Queue. +There is no Workflow implementation to register. + +```java +Worker worker = factory.newWorker(TASK_QUEUE_NAME); +worker.registerActivitiesImplementations(new GreetingActivitiesImpl()); +worker.registerNexusServiceImplementation(new GreetingNexusServiceImpl()); +``` + + +## Cancellation + +Worth remembering, because it is the one behavioral difference from a Workflow-backed Operation that surprises people. + +A Workflow is interrupted by a cancellation request. An Activity is not: the Worker only learns about it on the next heartbeat, so an Activity that never heartbeats runs until it completes or hits its timeout, no matter how many cancellation requests the caller sends. + +Nothing about this is Nexus-specific. See [Activity cancellation](/activity-execution#cancellation) for how to heartbeat, what to do with the resulting cancellation exception, and why a heartbeat timeout matters. For a short Activity that finishes well inside its timeout and doesn't have the risk of hanging, however, a heartbeat is not needed. + +## Choose between an Activity and a Workflow + +Back an Operation with an **Activity** when the work is a single step: one external call, one computation, one notification. +You get retries, timeouts, and a durable record of the step, without an Event History tracking orchestration you are not doing. + +Back an Operation with a **Workflow** when the work has more than one step, needs to wait for something, needs to receive [messages](/sending-messages), or needs durable intermediate state. +For example, an approval that blocks for a human decision is a Workflow, not an Activity. + +Sample code: `{code not yet live}` + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for the conceptual model. +- [Temporal Operation Handler](/develop/java/nexus/temporal-operation-handler) for the handler type and the Nexus-aware Client. +- [Standalone Activity](/standalone-activity) for the underlying concept, and [Java: Standalone Activities](/develop/java/activities/standalone-activities) for the SDK API. +- [Standalone Nexus Operation](/standalone-nexus-operation) for starting Operations without a caller Workflow. +- [Microservice Development Walkthrough](/develop/java/nexus/development-walkthrough) uses an Activity-backed Operation in context. + +::: diff --git a/docs/develop/java/nexus/client-code-generator.mdx b/docs/develop/java/nexus/client-code-generator.mdx new file mode 100644 index 0000000000..3f49637e5b --- /dev/null +++ b/docs/develop/java/nexus/client-code-generator.mdx @@ -0,0 +1,384 @@ +--- +id: client-code-generator +title: Nexus Client Code Generator - Java SDK +sidebar_label: Client Code Generator +description: How to install nexgen and generate typed Nexus models and Service definitions for Java. +toc_max_heading_level: 4 +slug: /develop/java/nexus/client-code-generator +tags: + - Nexus + - Java SDK +--- + +A [Nexus Service](/nexus/services) is a contract meant to be shared across team boundaries. +Those teams often work in different languages, so the same request and response types get hand-written for each SDK. +Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler. + +The **[Nexus Client Code Generator](https://github.com/temporalio/nexgen)** removes those copies. +You describe your types and [Nexus Operations](/nexus/operations) once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. +The generator is a command-line tool named `nexgen`, distributed from the [temporalio/nexgen](https://github.com/temporalio/nexgen) repository. + +For a short overview of what the generator produces and why, see [Nexus Client Code Generator](/nexus/client-code-generator). +This page covers installation, schema authoring, and generating and using Java output. + +:::caution + +`nexgen` is pre-release software and may not retain backwards compatibility with previous versions of the tool. +It is not yet published to any package registry, so you build it from source as described in [Install the generator](#install-the-generator). + +::: + +## What the generator produces + +The generator produces a client library in Go, Java, Python, or TypeScript for the inputs and outputs of your Nexus Operations. The generated types check values against the contract as they are sent and received, so a violation surfaces as an error rather than as bad data. + +That client library contains three things: + +- **A typed model.** An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema. +- **A runtime validator.** One validator per type, applied when a value is parsed off the wire and again when it is serialized onto it. See [Validation guarantees](#validation-guarantees) for more. +- **A [Nexus Service Contract](/glossary#nexus-service-contract) definition**, for a file that declares Services. These are the Service and Operation declarations you register on a Worker and call from a caller Workflow. A pure JSON Schema file declares none, so it produces only the models and their validators. + +Additionally, constraint failures are aggregated into a single native error listing every violation, each naming the offending field and the bound it broke. +A handler maps that error to a `BAD_REQUEST` [Nexus error](/nexus/error-handling), so a malformed request tells the caller everything that was wrong with it in one response. + +The supported schema subset is deliberately strict. +Anything ambiguous, or anything that cannot be expressed identically in all generated languages, is rejected when you run the generator with a diagnostic explaining how to express it correctly. +The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another. + +## Supported languages + +This page covers the Java output from `nexgen`. + +## Definition files + +Types are modeled with [JSON Schema 2020-12](https://json-schema.org/draft/2020-12). +A definition file is one of two kinds, decided by what sits at its root. +A file is one or the other, never both. + +**Pure JSON Schema.** The root of the document is itself a type, and reusable types live under `$defs`. +Use this when you only need data models shared across languages, with no Service or Operation declarations. + +**Nexus document.** Add a root `nexusrpc: "1.0.0"` marker to enable a `services` section. +The root becomes an envelope: Services and their Operations sit at the top level, and your types live under `$defs`. +Only this kind can declare a Service. + +The two kinds compose across files, so a contract is not limited to one of them. +A Service contract is often a Nexus document declaring the Services and Operations, plus pure JSON Schema files holding the types it `$ref`s by relative path. +The [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) closure described below is built that way. + +The examples on this page use [`samples/schemas/chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml) from the repository, abbreviated here: + +```yaml +nexusrpc: '1.0.0' +$schema: https://json-schema.org/draft/2020-12/schema +services: + ChatService: + fqn: example.chat.v1.ChatService + description: Send messages and look up rooms. + operations: + sendMessage: + description: Post a message to a room. + input: { $ref: '#/$defs/SendMessageInput' } + output: { $ref: '#/$defs/SendMessageOutput' } + getRoom: + description: Look up a room by id. + input: + type: object + additionalProperties: false + properties: + roomId: { type: string } + required: [roomId] + output: { $ref: '#/$defs/Room' } + ping: + description: Liveness probe. +$defs: + SendMessageInput: + type: object + additionalProperties: false + properties: + roomId: { type: string } + message: { $ref: '#/$defs/Message' } + required: [roomId, message] + SendMessageOutput: + type: object + additionalProperties: false + properties: + messageId: { type: string } + required: [messageId] +``` + +See [Definition files](https://github.com/temporalio/nexgen#definition-files) in the generator's README for details on that file. + +### How names are derived + +You write two kinds of name in the schema: one for the Service, and one for each Operation. In the sample above they are `ChatService` and `sendMessage`: + +```yaml +services: + ChatService: # the Service name + operations: + sendMessage: # the Operation name +``` + +Those are the only names you write. From each one the generator produces two more: a wire name and a name in your code. You declare neither of them. + +Give each name the casing that matches what it becomes: + +- A **Service** name is PascalCase: `ChatService`. A Service becomes a type in the generated code, and types are PascalCase. +- An **Operation** name is camelCase: `sendMessage`. An Operation becomes a method on that type, and the generator cases it like any other member. + +The first letter is the part the generator enforces: a Service has to start uppercase and an Operation lowercase. Both must begin with a letter and contain only letters and digits. + +``` +service name `chatService` must match `^[A-Z][a-zA-Z\d]+$` (start uppercase, then +letters/digits); set the wire name via `fqn` if it must differ +``` + +:::note Overriding the wire name + +The `fqn` in that error — a fully qualified name — is optional, and you can skip it to start. + +It lets a Service or Operation carry a wire name of your choosing rather than the one the generator derives from the name you wrote. Because it never becomes a code identifier, it accepts characters a name cannot: that is how a Service gets a wire name of `example.chat.v1.ChatService`, or an Operation one of `poll-messages`. Wire names are covered just below. + +Use `fqn` when you need to match a contract that is already published, or when you want a versioned, namespaced wire name. Otherwise leave it out. + +::: + +**The wire name** is the string the caller and the handler exchange, and what appears in Event History and the Temporal UI. Neither side types it — both take it from the generated code. + +Unless you override it, the wire name is the name from the definition file converted to PascalCase. In the sample that gives the Service a wire name of **`ChatService`** and the `sendMessage` Operation a wire name of **`SendMessage`**. + +In the sample above the Service does override it using `fqn`, so the Service wire name becomes **`example.chat.v1.ChatService`**. + +**The name in your code** is what you call. The generator recases the name you wrote to match the conventions of the language it is generating for. With no override in play, the two derived names line up like this: + +| You write | Wire name | Java | Go | Python | TypeScript | +| --- | --- | --- | --- | --- | --- | +| `ChatService` | `ChatService` | `ChatService` | `ChatService` | `ChatService` | `chatService` | +| `sendMessage` | `SendMessage` | `sendMessage` | `SendMessage` | `send_message` | `sendMessage` | + +Operations become camelCase methods in Java and TypeScript, snake_case in Python, and PascalCase in Go. + +An Operation's `input` and `output` are each optional. +The `ping` Operation above declares neither, which generates an Operation that takes and returns nothing. +When present, each must be an object type, so that a field can be added later without breaking the wire format. + +The repository holds four example definitions under [`samples/schemas/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas): +[`chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml), +the feature-diverse [`showcase.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/showcase.nexusrpc.yaml), +the pure-schema [`temporal.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/temporal.yaml), +and a multi-file closure under [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) showing how types split across files resolve through `$ref`. +The `kb/` closure starts at [`kb.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/kb/kb.nexusrpc.yaml) and pulls in types from its [`content/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/content) and [`tree/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/tree) subdirectories. + +## Install the generator + +Build the `nexgen` binary from source with Cargo, the Rust build tool: + +```bash +git clone https://github.com/temporalio/nexgen.git +cd nexgen +cargo build --release +``` + +The binary lands at `target/release/nexgen`. +Confirm it works and check which targets your build supports: + +```bash +./target/release/nexgen --version +./target/release/nexgen --help +``` + +## Generate code + +Every language uses the same shape: `nexgen ... --output `. +Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags. + +:::note + +The output directory name becomes the generated package or module name. +Name it after your domain, such as `chat`, not after the language. +Pointing `--output` at a directory named `go` produces `package go`, which is not valid Go. + +::: + +### Java + +Java requires `--package-name`. Point `--output` at the **full package path** beneath your source root, not just a directory named after the last segment: + +```bash +nexgen java samples/schemas/chat.nexusrpc.yaml \ + --output ./src/main/java/com/example/chat \ + --package-name com.example.chat +``` + +The generator checks only that the package name's last dot-separated segment matches the output directory's name. If they disagree, generation stops and tells you how to reconcile them: + +``` +`--package-name com.example.wrong` must end with the output directory name `chat`, +but its last segment is `wrong`; point `--output` at a directory named `wrong` or +change the package's last segment to `chat` +``` + +:::caution Passing that check is not enough to compile + +The check compares one segment; Java requires the file's location to match its whole package declaration. `--output ./src/chat --package-name com.example.chat` passes, because `chat` matches `chat`, and still produces files that declare `package com.example.chat` while sitting at `src/chat/`. + +Nothing fails at generation time, and nothing necessarily fails when you compile the generated files on their own. It breaks when something imports them: + +``` +Main.java:1: error: package com.example.chat does not exist +import com.example.chat.ChatService; +``` + +Always give `--output` the entire package path beneath your source root — `./src/main/java/com/example/chat` for `com.example.chat`. If files land in the wrong place, delete them and generate again with a corrected `--output`, rather than editing the `package` line to match. + +::: + +## Dates, times, and durations + +TypeScript's `--date-time-types` is the only place you choose how a date or time is represented. Elsewhere the generator decides: Java uses `java.time`, Python `datetime` and `timedelta`, and Go `time.Time` and `time.Duration`. Two cases hand you the wire string to work with instead of a date type — `format: time` in Java, and every date and time format under TypeScript's default `string` mode. + +Whichever type you get, every language writes the same bytes. Dates and times use [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339), which is a profile of [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601). ISO 8601 permits many optional spellings of the same instant, and RFC 3339 narrows them to one so two systems cannot read a timestamp differently. RFC 3339 specifies timestamps rather than durations, so durations follow ISO 8601. + +## How validation works + +Validation is the one behavior the generated types add, so a call can fail with a contract violation that hand-written types would not have caught. Only the wiring of that validation differs between languages. + +| SDK | How validation reaches the wire | Extra step | +| ---------- | ----------------------------------------------------------- | ---------- | +| Go | Generated `MarshalJSON` and `UnmarshalJSON` on each model | None | +| Java | Generated Jackson serializer and deserializer on each model | None | +| Python | Pydantic model validation | [Use the Pydantic data converter](/develop/python/data-handling/data-conversion#use-pydantic-models) | +| TypeScript | Generated mapper classes | Call the mapper yourself | + +In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. +TypeScript requires an explicit call, covered in [Validate payloads in TypeScript](/develop/typescript/nexus/client-code-generator#validate-payloads-in-typescript). + +### Validation guarantees + +The two directions do not check the same things. + +**Parsing a value off the wire** enforces required fields and every value constraint, aggregating all violations into one error. This is the direction that protects a handler from a malformed request. + +**Serializing a value onto the wire** enforces value constraints — lengths, bounds, counts, patterns. It does not report a required field you left unset. The field is omitted from the payload and the peer rejects it, so the failure surfaces as a `BAD_REQUEST` from the other side rather than as a local error at the point you built the object. + +For code that catches and logs a violation, see the per-language examples in [Use the generated code](#use-the-generated-code). + +## Use the generated code + +**Whether the code is generated or written by hand, you use it the same way.** It is a Service definition and a set of types: register it on a Worker, and call it from a caller Workflow exactly as described in your SDK's Nexus guide. The one difference is that generated types validate themselves, so a call can fail with a contract violation for you to catch. + +Each example below registers a handler, calls the Operation from a caller Workflow, and catches a validation failure. + +### Java + +The generator emits `ChatService` as an interface annotated with `@Service`, with one `@Operation` method per Operation. +On the handler side, write a separate implementation class that points at the generated interface with `@ServiceImpl`, and return an `OperationHandler` from each `@OperationImpl` method: + +```java +@ServiceImpl(service = ChatService.class) +public final class ChatServiceImpl { + @OperationImpl + public OperationHandler sendMessage() { + return OperationHandler.sync((ctx, details, input) -> new SendMessageOutput(store(input))); + } +} +``` + +Register it on a Worker with `worker.registerNexusServiceImplementation(new ChatServiceImpl())`. + +On the caller side, the same interface works directly as a Workflow stub. A payload that violates the contract fails at the call, so that is where you catch it: + +```java +ChatService chat = Workflow.newNexusServiceStub( + ChatService.class, + NexusServiceOptions.newBuilder() + .setEndpoint("chat-endpoint") + .setOperationOptions(NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofSeconds(10)) + .build()) + .build()); + +try { + SendMessageOutput output = chat.sendMessage(new SendMessageInput("r1", message)); +} catch (DataConverterException e) { + if (e.getCause() instanceof ValidationException ve) { + ve.getViolations().forEach(v -> log.error("{}: {}", v.getPath(), v.getReason())); + } else { + log.error("Payload conversion failed", e); + } + throw e; +} +``` + +`ValidationException` extends Jackson's `JsonMappingException`, so it is checked and always arrives wrapped. Converting the payload is what triggers it, so it reaches you as the cause of a `DataConverterException`, with the violation list intact. + +It is generated into each package, so a consumer of two generated Services has two unrelated exception types of the same name and needs a `catch` per package. + +## Regenerate after a contract change + +Generated files carry a `DO NOT EDIT` header and are replaced wholesale on the next run. There is no merge step, so anything you add to them is lost. + +Two habits make this safe: + +- **Commit generated code and regenerate as its own commit.** The diff then shows exactly what the contract change did to each language. +- **Fix names in the schema, not the output.** When a generated identifier is wrong for your language, set a per-language override in the contract so the fix survives regeneration. See [Naming and overrides](https://github.com/temporalio/nexgen#naming--overrides) for the available keys. + +If you generate into the wrong directory, delete what landed there and run the generator again with corrected flags. Do not edit the `package` or module declaration to match where the files ended up as the next run will overwrite it. + +## Schema defaults + +A property can declare a `default`, which makes it optional for a caller to supply: + +```yaml +sampleValue: + type: integer + default: 0 +``` + +A caller that leaves `sampleValue` unset sends a payload without the field, and the receiver reads `0`. The default is never written into the payload, so an omitted field stays omitted rather than being filled in before it is sent. + +:::caution Changing a default is a breaking change + +The default lives in the generated code, not in the payload. Temporal replays a Workflow by re-reading the payloads already recorded in its [Event History](/encyclopedia/event-history) using whatever code the Worker is running now, so changing a default changes what those recorded payloads mean. + +If the value affects which commands the Workflow produces, replaying an in-flight Workflow fails with a [non-determinism error](/troubleshooting/execution-failures#non-determinism-error) — the [deterministic constraints](/workflow-definition#deterministic-constraints) that govern any change to Workflow code apply here too. If it does not affect commands, nothing fails and the behavior changes silently, which is harder to catch. + +Treat a default as part of the contract. Adding a replacement field is not sufficient on its own: old payloads omit the new field too, so replay reads that field's default and can still diverge. Any change to how existing payloads are interpreted needs a [Workflow versioning](/workflow-definition#workflow-versioning) plan that keeps in-flight Executions on their original behavior. + +::: + +Because the field is optional, Go and Java give you two members side by side, so nothing depends on remembering that a default exists: + +- The field itself, which is empty when the caller omitted it — `getSampleValue()` returns `null` in Java, and `SampleValue` is a `nil *int64` in Go. +- An accessor named after it that substitutes the default — `getSampleValueOrDefault()` and `SampleValueOrDefault()`. + +Use the first when you need to know whether the caller supplied a value, and the second when you just want a number. + +TypeScript has no accessor. `sampleValue` is `undefined` when unset, and the generator exports a `DEFAULT_SAMPLE_VALUE` constant you apply yourself: `sampleValue ?? DEFAULT_SAMPLE_VALUE`. + +Python has neither. Pydantic applies defaults when the model is constructed, so `sample_value` always holds a value and an omitted field reads the same as one explicitly set to `0`. + +## Supported schema features + +The generator implements a curated subset of [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) chosen so that every accepted construct lowers identically into all generated languages. + +Fully supported: `properties`, `required`, `default`, `minProperties` and `maxProperties`, `dependentRequired`, string and numeric bounds, `items`, `minItems` and `maxItems`, `minContains` and `maxContains`, `allOf`, the recognized nullable pattern `oneOf: [{type: T}, {type: "null"}]`, and the `title`, `description`, and `deprecated` annotations. + +Partially supported: `type` (single-string form only), `additionalProperties`, `propertyNames`, `const` and `enum` (scalars only), `format`, `pattern` (a portable RE2-safe subset), `multipleOf`, `contentEncoding`, `uniqueItems`, `contains`, `oneOf` (branches must be separable by a decidable selector), and `$ref` with `$defs` (local files only). + +Deliberately rejected, because they have no coherent typed lowering across all generated languages: `anyOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `contentMediaType`, and `contentSchema`. + +For the current per-keyword support table, see the [nexgen README](https://github.com/temporalio/nexgen#supported-json-schema-features). + +:::tip RESOURCES + +- [temporalio/nexgen](https://github.com/temporalio/nexgen) for the generator, its README, and the example schemas. +- [Nexus Services](/nexus/services) for the Service contract concept. +- Nexus feature guides for registering Services and calling Operations: + [Go](/develop/go/nexus/feature-guide) | + [Java](/develop/java/nexus/feature-guide) | + [Python](/develop/python/nexus/feature-guide) | + [TypeScript](/develop/typescript/nexus/feature-guide) + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/add-a-standalone-activity.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/add-a-standalone-activity.mdx new file mode 100644 index 0000000000..1c7b1ede0c --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/add-a-standalone-activity.mdx @@ -0,0 +1,175 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +The last Operation is `notifyRequester`, which tells the requester their approval was `APPROVED` or `DENIED`. + +This one is not a Workflow. It is a single outbound notification with no state, nothing to wait for, and nothing to orchestrate — the [Standalone Activity](/nexus/standalone-activity) shape chosen in [step 3](/develop/java/nexus/development-walkthrough?step=backing). + +## Write the Activity + +The Activity is an ordinary Activity. In this walkthrough it is a placeholder that does nothing — no email is sent. Real logic would call an email provider, push to a notification service, or write to an outbox. + +Nothing in it is Nexus-specific. The same Activity Function can be invoked from a Workflow and started behind this Operation with no code changes — what differs is what starts it, not how it is written. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivities.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalActivities.java) + +```java +public interface ApprovalActivities { + + /** STEP 4 placeholder - real logic would apply policy, check limits, or call a risk service. */ + @ActivityMethod + void evaluateAutoDecision(String itemId, double amount); + + /** STEP 4 placeholder - real logic would page an approver or open a ticket. */ + @ActivityMethod + void notifyApproverOfPendingRequest(String itemId, String requester); + + /** + * STEP 9 - The Standalone Activity behind the notifyRequester Operation. One outbound + * notification, no state, nothing to wait for. In this sample it only logs; real logic would call + * an email provider, push to a notification service, or write to an outbox. + */ + @ActivityMethod + NotifyRequesterOutput notifyRequester(String requester, NotifyRequesterInput.Decision decision); +} +``` + + + +## Back the Operation with it + +Use `TemporalOperationHandler` as with every other Operation, but start an Activity on the [Client](/nexus/temporal-operation-handler#the-nexus-aware-client) instead of a Workflow. The Operation starts an Activity Execution with no parent Workflow and completes when the Activity returns. + +This is the right shape whenever an Operation is one durable step behind a team boundary. The Activity supplies the durability — retries on the policy you set, timeouts you control, and a record of every attempt — and the Operation supplies the contract, so the notification is reachable by other teams without them sharing your code or your Namespace. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler notifyRequester() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startActivity( + ApprovalActivities.class, + ApprovalActivities::notifyRequester, + input.getRequester(), + input.getDecision(), + StartActivityOptions.newBuilder() + // Deriving the Activity Id from the request Id keeps a retried Nexus start + // request targeting the same Activity Execution instead of sending a second + // notification. + .setId("notify-" + ctx.getRequestId()) + .setTaskQueue(HandlerWorker.DEFAULT_TASK_QUEUE_NAME) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .setScheduleToCloseTimeout(Duration.ofMinutes(5)) + .setRetryOptions(RetryOptions.newBuilder().setMaximumAttempts(3).build()) + .build())); + } +``` + + + +### Options an Activity-backed Operation requires + +`StartActivityOptions` needs an **Activity Id**, unique within the Namespace, which a Workflow-called Activity does not, because there is no parent Workflow to scope it. + +The Task Queue is optional and defaults to the one the Operation is running on. Set it explicitly to run notifications on their own Worker fleet rather than the one the Endpoint targets. + +**To keep server retries from sending a second email, derive the Activity Id from the Nexus request Id.** The server retries Nexus start requests, and the request Id travels with them, so every retry lands on the same Activity Id and the notification goes out once. Without that, a retried request is a duplicate message to a real person. + +The same pattern applies to any Operation whose work is externally visible and cannot be taken back: charging a card, posting to a webhook, creating a ticket, writing to a system with no dedup of its own. Deriving the Id from the request Id costs nothing and removes the whole class of duplicate-side-effect bugs. + +Deriving the Id from the Operation *input* instead is a different tool for a different job: it makes several Operations share one Activity Execution and all receive its result. See [Nexus Standalone Activity](/develop/java/nexus/activity-backed-operations#required-options). + +## Register the Activity on the Worker + +Add the Activity implementation to the same Worker that hosts the Nexus Service. An Activity-backed Operation needs no Workflow implementation registered for it. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java) + +```java + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(DEFAULT_TASK_QUEUE_NAME); + + worker.registerWorkflowImplementationTypes(ApprovalWorkflowImpl.class); + worker.registerActivitiesImplementations(new ApprovalActivitiesImpl()); + worker.registerNexusServiceImplementation(new ApprovalServiceImpl()); + + factory.start(); + } +``` + + + +## Cancellation needs heartbeating + +This notification finishes immediately, so cancellation never comes up for it. It does come up for any longer Activity-backed Operation, and the behavior differs from a Workflow-backed one: an Activity is not interrupted by a cancellation request, so an Activity that never heartbeats runs to completion or to its timeout no matter how many cancellations arrive. + +If you write a long-running Activity-backed Operation, read [Activity cancellation](/activity-execution#cancellation) before you ship it. None of the mechanics are Nexus-specific. + +## Run it + +Restart the handler Worker and call the Operation. From the command line it looks like every other +Operation, which is the point: + + + +```bash title="Run 1 of 2: notify the requester" +temporal nexus operation execute \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation NotifyRequester \ + --operation-id notify-1 \ + --input '{"requester":"tao@example.com","decision":"APPROVED"}' +``` + + + +``` +Results: + Status COMPLETED + Result {"deliveredTo":"tao@example.com"} +``` + +The difference is on the handler side. There is no Workflow for this one, and the Activity +Execution shows up with no parent: + + + +```bash title="Run 2 of 2: list the Standalone Activity" +temporal activity list --namespace approval-handler-namespace +``` + + + +``` + Status ActivityId Type StartTime + Completed notify-0f816331-6b85-404d-9dfa-dd249041c1ea NotifyRequester now +``` + +The Activity Id is the one the handler derived from the Nexus request Id. Run the same command +again with a new `--operation-id` and a second Activity Execution appears; a server retry of the +*same* request reuses the first one, which is what keeps a retry from sending a second +notification. + +If this fails with `completion callbacks are not enabled for this namespace`, the development +server was started without `activity.enableCallbacks=true`. See +[Before you start](/develop/java/nexus/development-walkthrough?step=overview#start-the-development-server). + + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for the full concept and options. +- [Standalone Activity](/standalone-activity) for Activity Executions outside a Workflow. +- [Java: Standalone Activities](/develop/java/activities/standalone-activities) for the SDK API. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/add-messaging.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/add-messaging.mdx new file mode 100644 index 0000000000..f516a5f8c4 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/add-messaging.mdx @@ -0,0 +1,255 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +The approval blocks waiting for a decision. **Messages** give callers a way to interact with it while it waits. + +Two Operations get added to the workflow sample problem. Which [message](/sending-messages) type each one uses is decided by what the caller needs back, not by preference. + +| Operation | Message type | Why this type | +| --- | --- | --- | +| `remindApprover` | Signal | Fire-and-forget. The caller does not need a response, only for the nudge to happen. | +| `submitDecision` | Update | Changes state *and* returns a result the caller needs — confirmation the decision was recorded. | + +That is the whole rule. If the caller can proceed without hearing anything back, a Signal is enough. If the caller needs to know what the message did, it needs an Update. + +## Add the handlers to the Workflow + +On the Workflow, add a Signal handler that increments the reminder count and an Update handler that records the decision and unblocks the wait. + +The Update is what ends the approval. It records `APPROVED` or `DENIED`, which satisfies the condition the Workflow is blocked on, and the Workflow then returns that decision as its result. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflow.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflow.java) + +```java +public interface ApprovalWorkflow { + + /** + * The Update handler's name on the wire. The Update-backed Operation has to name the Update + * explicitly when it starts one, so the name is declared once here and reused there rather than + * being spelled as a literal in two places. + */ + String SUBMIT_DECISION_UPDATE = "submitDecision"; + + /** + * STEP 4 - The Workflow method. Its return value is the result of the requestApproval Operation: + * the Operation completes when this Workflow returns, and the caller receives this value through + * the Nexus completion callback. + * + *

Because the Workflow's return value is delivered straight to the caller as the Operation + * result, it has to be the Operation's declared output type. + */ + @WorkflowMethod + RequestApprovalOutput runApproval(RequestApprovalInput input); + + /** + * STEP 7 - A Signal. Fire-and-forget: the caller gets no result back, which is why a Signal is + * the right message type for a nudge, and why the contract declares no output for it. + */ + @SignalMethod + void remindApprover(); + + /** + * STEP 8 - A Signal that also carries supporting information. Reached through Signal-with-Start, + * so it may be the message that creates this Workflow. + */ + @SignalMethod + void attachContext(String note); + + /** + * STEP 7 - An Update. The caller needs a result back - confirmation that the decision was + * recorded - which is what makes this an Update rather than a Signal. + */ + @UpdateMethod(name = ApprovalWorkflow.SUBMIT_DECISION_UPDATE) + SubmitDecisionOutput submitDecision(SubmitDecisionInput.Decision decision); + + /** + * STEP 7 - The Update's validator. An Update can reject a request before it changes anything, + * which a Signal cannot: a Signal has already been accepted by the time the handler runs. + * + *

Here it rejects a second decision for an approval that has already been decided. Without it + * the later decision would silently overwrite the earlier one. A rejected Update does not appear + * in Event History and does not run the handler. + */ + @UpdateValidatorMethod(updateName = ApprovalWorkflow.SUBMIT_DECISION_UPDATE) + void validateSubmitDecision(SubmitDecisionInput.Decision decision); +} +``` + + + +## Expose them as Nexus Operations + +Both use `TemporalOperationHandler`, and they divide along the line described in [The Nexus-aware Client](/nexus/temporal-operation-handler#the-nexus-aware-client): a Signal is **sync messaging**, and an Update is an **async backing**. + +### Signal + +Send the Signal through the Client, then return a synchronous result. The Operation completes immediately, during the handler call. + +:::caution The handler has under 10 seconds + +A synchronous handler must finish inside the [10-second handler deadline](/cloud/limits#nexus-operation-request-timeout), and the budget you actually get is smaller: the clock starts on the caller's side and the request still has to route through matching. + +Sending one Signal is comfortably inside it. A handler that sends several messages, or does slow work before returning, is not. Overrunning gives the caller a context deadline exceeded error, which it then retries with exponential backoff until the schedule-to-close timeout expires. + +::: + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler remindApprover() { + return TemporalOperationHandler.create( + (ctx, client, input) -> { + client + .getWorkflowClient() + .newWorkflowStub( + ApprovalWorkflow.class, ApprovalWorkflowId.forItem(input.getItemId())) + .remindApprover(); + return TemporalOperationResult.sync(null); + }); + } +``` + + + +### Update + +Start the Update on the Client. This is an async backing: the Operation completes when the Update completes, and its result is delivered through the Nexus completion callback. If the Update happens to come back already complete — a retried request, or one that failed validation — the result returns synchronously instead. + +An Update-backed Operation carries two requirements. It targets a Workflow that already exists, so a `submitDecision` for a purchase with no approval running fails. And because it is an async backing, there is at most one per Operation invocation, though a handler can still combine it with sync side effects. + +### Reject a bad Update before it changes anything + +An Update can also refuse a request, which is the other thing a Signal cannot do. By the time a Signal handler runs the message has already been accepted and written to history; there is nowhere left to say no. + +The approval uses that. A **validator** runs before the handler and rejects a second decision for an approval that has already been decided — without it, the later decision would silently overwrite the earlier one. A rejected Update never runs the handler, never reaches Event History, and surfaces to the caller as a failed Operation. + +The validator is the method annotated `@UpdateValidatorMethod` in the Workflow interface above. It takes the same arguments as the handler, returns nothing, and must not change Workflow state. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler submitDecision() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startWorkflowUpdate( + ApprovalWorkflow.class, + ApprovalWorkflowId.forItem(input.getItemId()), + ApprovalWorkflow::submitDecision, + input.getDecision(), + UpdateOptions.newBuilder() + .setResultClass(SubmitDecisionOutput.class) + // The Update to invoke has to be named explicitly; the method reference above + // supplies the argument types but not the wire name. + .setUpdateName(ApprovalWorkflow.SUBMIT_DECISION_UPDATE) + // An Update-backed Operation must wait for the ACCEPTED stage. The Operation + // completes later, when the Update completes, through the completion callback. + // Any other stage is rejected with "nexus op workflow updates only support + // WorkflowUpdateStageAccepted for async updates". + .setWaitForStage(WorkflowUpdateStage.ACCEPTED) + .build())); + } +``` + + + +## Do not poll for the decision + +There is one design mistake worth naming, because it is the most common one in this shape: reaching for a message to fetch the final decision. + +The decision is the result of `requestApproval`, and it reaches the caller without anyone asking for it. + +When the handler started the approval, Nexus attached a [completion callback](/glossary#nexus-async-completion-callback) to that Workflow. The moment the Workflow returns, the handler's Namespace delivers the callback to the caller's Nexus Machinery, which records a `NexusOperationCompleted` event in the caller Workflow's history. The caller Worker picks that up on its next Workflow Task, and the caller Workflow resumes with the decision. See the [asynchronous Operation lifecycle](/nexus/operations#asynchronous-operation-lifecycle) for the full sequence. + +A caller that instead asks the approval for its status in a loop is polling for something already on its way. + +Messages are for changing a running approval or nudging it along, not for collecting its outcome. `remindApprover` asks the approver again. `submitDecision` supplies the decision and confirms it landed. Neither is a way to read the result. + +Both also stop working the moment the approval completes. The Temporal Service accepts a Signal or an Update only while the Workflow is still running, and rejects one sent to a closed Workflow with `NOT_FOUND: workflow execution already completed`. That happens as soon as the decision lands, not when the [Retention Period](/temporal-service/temporal-server#retention-period) later expires and the Execution is deleted. A second `submitDecision` for an approval that has already been decided fails this way, and so does any attempt to use these Operations to look up a past decision. + +If more than one system needs the outcome, see [step 8](/develop/java/nexus/development-walkthrough?step=send#when-the-approval-already-exists) for how additional callers attach to a running approval and receive the same decision. + +## Run the messaging Operations + +Restart the handler Worker to pick up the two new Operations, then nudge the approval left running +at the end of [step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-operations). +The Signal returns nothing: + + + +```bash title="Run 1 of 3: nudge the approver" +temporal nexus operation execute \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation RemindApprover \ + --operation-id remind-1 \ + --input '{"itemId":"laptop-42"}' +``` + + + +``` +Results: + Status COMPLETED + Result null +``` + +The Update returns the confirmation the caller needs, including the reminder count the Workflow has +been keeping: + + + +```bash title="Run 2 of 3: submit the decision" +temporal nexus operation execute \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation SubmitDecision \ + --operation-id decide-1 \ + --input '{"itemId":"laptop-42","decision":"APPROVED"}' +``` + + + +``` +Results: + Status COMPLETED + Result {"recorded":"APPROVED","remindersSent":1} +``` + +That decision unblocks the approval Workflow, which returns and completes the `requestApproval` +Operation still open from step 4. Collect its result: + + + +```bash title="Run 3 of 3: collect the approval result" +temporal nexus operation result \ + --namespace approval-caller-namespace --operation-id approval-1 +``` + + + +``` +Results: + Status COMPLETED + Result {"decision":"APPROVED"} +``` + +Nothing polled for that decision. The Operation completed because the approval Workflow returned, +and `result` waited on the completion callback rather than asking the approval for its status. + + +:::tip RESOURCES + +- [Workflow message passing](/encyclopedia/workflow-message-passing) for Signals and Updates. +- [Handling messages](/handling-messages) for handler constraints. +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the sync messaging and async backing distinction. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/call-the-service.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/call-the-service.mdx new file mode 100644 index 0000000000..1f5f1d77af --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/call-the-service.mdx @@ -0,0 +1,247 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +Call the approval Operations from a Workflow in the caller Namespace. The caller knows two things — the Endpoint name and the contract from [step 1](/develop/java/nexus/development-walkthrough?step=contract) — and nothing else about the handler. + +The caller built here is Java, the same language as the handler, but nothing about the handler requires that. [Call it from another language](#call-it-from-another-language) covers the cross-language case, which is the same call against the same Endpoint. + +## What the caller gets from the contract + +The caller does not hand-write request types, response types, or Operation names. [Step 2](/develop/java/nexus/development-walkthrough?step=generate) generated all of it from the contract, and the caller works against that generated code: + +- **A Service definition** naming the Service and its Operations, so an Operation name is a symbol rather than a string you can misspell. +- **Typed models** for every input and output in the contract. +- **Runtime validators** that reject a payload violating the contract before it reaches the wire. + +The practical effect is that the contract is enforced twice. A field the contract does not have fails at build time in a typed language, and a payload the contract forbids fails at the boundary rather than inside the handler's Workflow. + +## Call the Operations from a caller Workflow + +The flow follows the walkthrough sample problem. Check whether the purchase needs approval at all; if it does, request one and wait for the decision. + +In Java, the generated Service interface works directly as a Nexus Service stub. Create it inside the caller Workflow with the Endpoint name and Operation options, then call its methods as if they were local. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflowImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/ApprovalCallerWorkflowImpl.java) + +```java +public class ApprovalCallerWorkflowImpl implements ApprovalCallerWorkflow { + + private static final Logger logger = Workflow.getLogger(ApprovalCallerWorkflowImpl.class); + + // STEP 6 - In Java the Service interface works directly as a Nexus Service stub. Because the stub + // is that interface, every call below is type-checked against the contract at compile time. + // + // The schedule-to-close timeout bounds the whole Operation. A human approval measured in days + // would need a timeout in days; this sample decides in seconds, so a short one is fine. The + // default would not be right for a real approval. + private final ApprovalService approvalService = + Workflow.newNexusServiceStub( + ApprovalService.class, + NexusServiceOptions.newBuilder() + .setOperationOptions( + NexusOperationOptions.newBuilder() + .setScheduleToCloseTimeout(Duration.ofMinutes(2)) + .build()) + .build()); + + @Override + public String runApprovalFlow(String itemId, String requester, double amount, String note) { + + // ------------------------------------------------------------------------------------------- + // STEP 6 - A synchronous Operation. It returns during the call because nothing durable backs + // it: no callback, no Operation token, nothing to await. A caller can use it to skip the rest + // of this Service entirely. + // ------------------------------------------------------------------------------------------- + CheckApprovalRequiredOutput check = + approvalService.checkApprovalRequired( + new CheckApprovalRequiredInput(itemId, requester, amount)); + + logger.info( + "checkApprovalRequired -> required={} threshold={}", + check.getApprovalRequired(), + check.getThreshold()); + + if (!check.getApprovalRequired()) { + return "NO_APPROVAL_REQUIRED"; + } + + // ------------------------------------------------------------------------------------------- + // STEP 8 - Attach information before the approval exists. + // + // This is deliberately called BEFORE requestApproval, which is the harder ordering. Because + // attachApprovalContext is Signal-with-Start, this call creates the approval Workflow and + // delivers the note to it. + // ------------------------------------------------------------------------------------------- + approvalService.attachApprovalContext( + new AttachApprovalContextInput(itemId, requester, amount, note)); + logger.info("attachApprovalContext -> note attached, approval now exists"); + + // ------------------------------------------------------------------------------------------- + // STEP 6 - Request the approval. + // + // The approval Workflow is already running thanks to the call above, so this start would fail + // under the default conflict policy. The handler sets USE_EXISTING, so instead this attaches + // the Operation's completion callback to the running Execution. + // + // startNexusOperation returns a handle rather than blocking, so this Workflow can keep working + // while the approval is pending. The wait is durable: this caller can be evicted and its Worker + // can restart, and the result still arrives. + // ------------------------------------------------------------------------------------------- + NexusOperationHandle approvalHandle = + Workflow.startNexusOperation( + approvalService::requestApproval, new RequestApprovalInput(itemId, requester, amount)); + + // Wait for the Operation to be started before messaging it. NexusOperationExecution carries the + // Operation token for an asynchronous Operation. + approvalHandle.getExecution().get(); + logger.info("requestApproval -> started and attached to the existing approval"); + + // ------------------------------------------------------------------------------------------- + // STEP 8 - Nudge the pending approval. A Signal, so there is no result to collect. + // ------------------------------------------------------------------------------------------- + approvalService.remindApprover(new RemindApproverInput(itemId)); + logger.info("remindApprover -> approver nudged"); + + // ------------------------------------------------------------------------------------------- + // STEP 8 - Submit the decision. An Update, so the caller gets confirmation back. + // + // In a real system this arrives from a human through a separate caller. The sample submits it + // here so the flow completes without one. + // ------------------------------------------------------------------------------------------- + SubmitDecisionOutput ack = + approvalService.submitDecision( + new SubmitDecisionInput(itemId, SubmitDecisionInput.Decision.DECISION_APPROVED)); + logger.info( + "submitDecision -> recorded={} after {} reminder(s)", + ack.getRecorded().getValue(), + ack.getRemindersSent()); + + // ------------------------------------------------------------------------------------------- + // STEP 6 - Await the decision. + // + // The caller does not poll. The decision is the result of requestApproval, pushed here through + // the Nexus completion callback the moment the approval Workflow returns. Asking the approval + // for its status in a loop would be polling for something already on its way. + // ------------------------------------------------------------------------------------------- + RequestApprovalOutput.Decision decision = approvalHandle.getResult().get().getDecision(); + logger.info("requestApproval -> decision {}", decision.getValue()); + + // ------------------------------------------------------------------------------------------- + // STEP 10 - Call the Standalone Activity. + // + // From the caller this looks like any other Operation. It does not know that nothing but a + // single Activity Execution sits behind it. + // ------------------------------------------------------------------------------------------- + NotifyRequesterOutput notified = + approvalService.notifyRequester( + new NotifyRequesterInput( + requester, NotifyRequesterInput.Decision.fromString(decision.getValue()))); + logger.info("notifyRequester -> delivered to {}", notified.getDeliveredTo()); + + return decision.getValue(); + } +} +``` + + + +The Endpoint name is not in the Workflow. It is bound once when the caller Worker registers the +Workflow, so the Workflow refers to the Service by its contract alone: + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerWorker.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerWorker.java) + +```java + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(DEFAULT_TASK_QUEUE_NAME); + + worker.registerWorkflowImplementationTypes( + WorkflowImplementationOptions.newBuilder() + .setNexusServiceOptions( + Collections.singletonMap( + SERVICE_NAME, + NexusServiceOptions.newBuilder().setEndpoint(DEFAULT_ENDPOINT_NAME).build())) + .build(), + ApprovalCallerWorkflowImpl.class); + + factory.start(); + } +``` + + + +Nothing in this caller is aware of how the handler is built. It does not know which Task Queue the handler's Worker polls, or that `requestApproval` is backed by a Workflow while `checkApprovalRequired` is backed by nothing at all. It knows the Endpoint name and the contract. + +Run it in a third terminal, alongside the handler Worker from +[step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker): + + + +```bash title="Run: start the caller Worker" +./gradlew -q :core:execute \ + -PmainClass=io.temporal.samples.nexuswalkthrough.caller.CallerWorker \ + --args="-target-host localhost:7233 -namespace approval-caller-namespace" +``` + + + +It polls the caller Namespace and does nothing until a caller Workflow is started, which +[Finish](/develop/java/nexus/development-walkthrough?step=call-activity#run-the-whole-flow) does. + +That is the property worth pausing on: the handler team can change what backs an Operation, move the handler to another Namespace, or rewrite it in another language, and this caller keeps working. + +## Await the decision + +`requestApproval` returns `APPROVED` or `DENIED`. That value is the approval Workflow's return value, delivered to the caller through the Nexus completion callback when the Workflow finishes. + +The caller does not poll. It awaits the Operation, and the wait is durable — the caller Workflow can be evicted, the Worker can restart, and the result still arrives. + +`checkApprovalRequired` behaves differently and it is worth noticing the contrast. It returns during the call, because nothing durable backs it. There is no callback, no Operation token, and nothing to await. + +### Set timeouts + +A caller sets three timeouts on a Nexus Operation, each bounding a different stage: + +- **Schedule-to-close** bounds the whole Operation, from scheduling to completion. Set it to reflect how long an approval can legitimately take — a human approval measured in days needs a timeout in days, and the default is not going to be right. +- **Schedule-to-start** bounds how long the caller waits for the handler to pick the Operation up. Set it when you want a handler that is down to fail fast, even though the approval itself may run for days. +- **Start-to-close** bounds an asynchronous Operation after it has started. Synchronous Operations like `checkApprovalRequired` ignore it, because they complete as part of the start request. + +See [Nexus Operations](/nexus/operations#timeouts) for the full timeout model. + +## Call it from another language + +The caller does not have to be written in the same language as the handler. Each language has a sample repository that builds this same approval Service from this same contract, and each one carries a working caller as well as a working handler: + +| Language | Sample | +| --- | --- | +| Go | `{sample repo link}` | +| Python | `{sample repo link}` | +| TypeScript | `{sample repo link}` | + +Check the README in each repository for how to run its client. Point it at the Endpoint created in [step 5](/develop/java/nexus/development-walkthrough?step=publish) and it drives the Java handler built here, with no changes on either side. + +The interop runs both directions. Every one of those clients can call this Java Service, and the Java caller built in this step can call the Service from any of those repositories. The contract is the only thing the two sides share, so neither side needs to know the other's language, Namespace, or deployment. + +To generate a caller for another language from this contract yourself rather than running a sample, see [Generate code](/develop/java/nexus/client-code-generator#generate-code). + +## Calling without a caller Workflow + +A caller Workflow is the usual pattern and the one this walkthrough uses, because a Workflow gives the call durability and lets you orchestrate around it. + +If you only need to run one Operation and have nothing to orchestrate, a Client can start an Operation directly with no caller Workflow at all. That is a [Standalone Nexus Operation](/standalone-nexus-operation), and it uses the same Service contract, the same handler, and the same Endpoint — only the caller side differs. See [Java: Standalone Operations](/develop/java/nexus/standalone-operations). + +`checkApprovalRequired` is a natural fit for this. A caller that only wants to know whether approval is needed has nothing to orchestrate and no result to await. + + +:::tip RESOURCES + +- [Nexus Operations](/nexus/operations) for the Operation lifecycle and timeouts. +- [Java Nexus feature guide](/develop/java/nexus/feature-guide) for the caller API. +- [Nexus Client Code Generator](/develop/java/nexus/client-code-generator) for generating callers in other languages. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/call-the-standalone-activity.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/call-the-standalone-activity.mdx new file mode 100644 index 0000000000..b71e28643f --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/call-the-standalone-activity.mdx @@ -0,0 +1,125 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +Call `notifyRequester` once the decision is final. From the caller's side there is nothing new to learn, which is the point of this step. + +## The caller cannot tell the difference + +`notifyRequester` is called exactly like `requestApproval`: through the same generated stub, with the same Endpoint, the same type checking, and the same error handling. A caller in any language calls it the same way, as in [step 6](/develop/java/nexus/development-walkthrough?step=call). + +Nothing in the caller reveals that this Operation is backed by an Activity and the other by a Workflow. That is the contract doing its job. The handler team could later replace the notification Activity with a Workflow that retries across providers and escalates on failure, and no caller would change. + +The call sits alongside the others in the caller Workflow from [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow) — same stub, same shape, no hint of what runs behind it. + +## Complete the flow + +With all ten steps in place, the caller runs the whole approval: + +1. Call `checkApprovalRequired`. It answers during the call, with nothing durable created. If the purchase is under the threshold, the flow stops here. +2. Call `requestApproval` and await it. The Operation starts the approval Workflow in the handler Namespace, or attaches to one that another Operation already started. +3. While it is pending, other systems call `remindApprover` to nudge the approver and `attachApprovalContext` to add supporting information. +4. Someone calls `submitDecision` with `APPROVED` or `DENIED`. The Update records it, confirms to that caller, and unblocks the approval Workflow. +5. The approval Workflow returns the decision, which resolves the `requestApproval` Operation every attached caller has been awaiting. +6. The caller calls `notifyRequester` with the decision, backed by the notification Activity. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerStarter.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/caller/CallerStarter.java) + +```java + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkflowOptions options = + WorkflowOptions.newBuilder().setTaskQueue(CallerWorker.DEFAULT_TASK_QUEUE_NAME).build(); + + // A small purchase. checkApprovalRequired returns false and nothing durable is created. + ApprovalCallerWorkflow small = client.newWorkflowStub(ApprovalCallerWorkflow.class, options); + String smallResult = + small.runApprovalFlow( + "laptop-charger-" + UUID.randomUUID(), + "dana@example.com", + 49.99, + "Replacement charger"); + logger.info("Small purchase result: {}", smallResult); + + // A large purchase. Runs the whole flow: context attached first, approval requested, approver + // reminded, decision submitted, decision awaited, requester notified. + ApprovalCallerWorkflow large = client.newWorkflowStub(ApprovalCallerWorkflow.class, options); + String largeResult = + large.runApprovalFlow( + "standing-desk-" + UUID.randomUUID(), + "dana@example.com", + 1250.00, + "Approved in the Q3 ergonomics budget"); + logger.info("Large purchase result: {}", largeResult); + } +``` + + + +## Run the whole flow + +Three terminals, all from the repository root. The handler Worker from +[step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker) and the caller +Worker from [step 6](/develop/java/nexus/development-walkthrough?step=call) should already be +running. Start the Starter in the third: + + + +```bash title="Run: run the whole flow" +./gradlew -q :core:execute \ + -PmainClass=io.temporal.samples.nexuswalkthrough.caller.CallerStarter \ + --args="-target-host localhost:7233 -namespace approval-caller-namespace" +``` + + + +``` +INFO i.t.s.n.caller.CallerStarter - Small purchase result: NO_APPROVAL_REQUIRED +INFO i.t.s.n.caller.CallerStarter - Large purchase result: APPROVED +``` + +The Starter exits when both runs finish; the two Workers keep running. The handler Worker shows the +large purchase moving through every Operation: + +``` +INFO ApprovalWorkflowImpl - Context attached: Approved in the Q3 ergonomics budget +INFO ApprovalActivitiesImpl - Evaluating auto-decision rules for standing-desk-... at 1250.0 +INFO ApprovalActivitiesImpl - Approver notified that standing-desk-... is waiting +INFO ApprovalWorkflowImpl - Approver reminded, 1 reminder(s) so far +INFO ApprovalWorkflowImpl - Approval for standing-desk-... decided APPROVED after 1 reminder(s) and 1 note(s) +INFO ApprovalActivitiesImpl - Notifying dana@example.com that their request was APPROVED +``` + +Note the first line. The context is attached *before* the approval is requested, which is the +Signal-with-Start ordering from [step 8](/develop/java/nexus/development-walkthrough?step=send#run-it-in-the-harder-order). +`requestApproval` then attached to the approval that the note created rather than failing on it. + +Every step crossed a Namespace boundary, and the caller never learned a Workflow Id, a Task Queue, or which primitive backed any Operation. One Operation ran with no durable Execution at all, one started a Workflow, two sent messages to it, one started a Workflow if it was not already running, and one started an Activity — and from the caller's side they were all just Operations. + +## Trace it end to end + +Open the caller Workflow in the UI and follow the links. Because the handlers used the [Client](/nexus/temporal-operation-handler#the-nexus-aware-client) rather than constructing their own, each Operation is connected to the Execution it started or messaged in the handler Namespace, and you can move between the two Namespaces in one view. + +`checkApprovalRequired` is the exception, and not because anything is wrong: it started no Execution, so there is nothing on the handler side to link to. An Operation with no backing appears as a completed Operation and nothing more. + +## Where to go next + +The Service is complete but minimal. Natural extensions: + +- **Timeouts and escalation.** Give the approval a deadline and escalate or auto-deny when it passes. +- **Split the Workers.** Run the Nexus Service, the approval Workflow, and the notification Activity on separate Worker fleets. See [Nexus patterns](/nexus/patterns). +- **Callers in other languages.** Generate a caller from the same contract in Go, Python, or TypeScript. See [Nexus Client Code Generator](/develop/java/nexus/client-code-generator). +- **Standalone invocation.** Call an Operation from a Client with no caller Workflow. See [Standalone Nexus Operation](/standalone-nexus-operation). + +Before running this against anything real, read [Debugging, common pitfalls, and tips](/develop/java/nexus/development-walkthrough?step=tips). + +Back to the [Microservice Development Walkthrough overview](/develop/java/nexus/development-walkthrough). + +:::tip RESOURCES + +- [Nexus execution debugging](/nexus/execution-debugging) for tracing across Namespaces. +- [Nexus patterns](/nexus/patterns) for Worker and Service topology. +- [Nexus Standalone Activity](/nexus/standalone-activity) for Activity-backed Operations. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/choose-backing-implementation.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/choose-backing-implementation.mdx new file mode 100644 index 0000000000..ab06d498e7 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/choose-backing-implementation.mdx @@ -0,0 +1,119 @@ +The contract says nothing about what runs behind an Operation. That is deliberate — it is the handler's private decision, and it can change later without touching callers. + +There are three choices, and picking the wrong one is the most common source of trouble later. This step makes the choice for each Operation in the walkthrough's example approval Service, then implements the simplest one. + +## No backing Execution + +The handler computes an answer and returns it. Nothing durable is created: no Workflow, no Activity, nothing to cancel, nothing in Event History. + +Use this when the Operation applies a rule to its input and returns: deriving a value, reading configuration the handler already holds, or checking a precondition. The Operation completes during the handler call, and the caller gets the answer in the response. + +**Validation is the clearest case.** When an Operation rejects its input, starting a Workflow or an Activity first would be wrong: you would create an Execution whose only job is to fail immediately, and record that failure in a Namespace that now has to be cleaned up. A handler with no backing Execution checks its preconditions, returns the rejection in the response, and leaves nothing behind. + +The limit is that you get no durability. The code runs inside the Nexus Operation handler, so it is bounded by the request deadline, and a failure fails the request rather than retrying a step. + +So the question is not whether the Operation can fail, but what should happen when it does. Bad input stays bad, so retrying a rejection only fails again: return the rejection and use no backing Execution. A failure that a retry could fix, such as an unreachable external API, needs an Activity. + +## Workflow + +More than one step, any need to wait, or any need for durable intermediate state. + +The Operation starts a Workflow and completes when that Workflow returns, so the Workflow's return value is the Operation's result. Use this when the work orchestrates several Activities, needs a timer, needs to receive [messages](/sending-messages) while it runs, or needs to survive a Worker restart partway through. + +A Workflow that represents one long-lived thing and stays reachable for messages is still just a Workflow — what makes it interactive is that it has message handlers and a Workflow Id you can predict, not a different kind of primitive. + +## Standalone Activity + +One step, no waiting, no state. Call an external API, run a computation, send a notification. + +The Operation starts an Activity Execution with no parent Workflow, and completes when the Activity returns. You get retries, timeouts, and a durable record of every attempt without a wrapper Workflow that exists only to call one Activity. + +The tradeoff is that an Activity cannot receive messages or hold state, and cancellation only works if the Activity heartbeats. See [Nexus Standalone Activity](/nexus/standalone-activity). + +## The choice for the approval Service + +| Operation | Backing | Why | +| --- | --- | --- | +| `checkApprovalRequired` | None | Applies a threshold to the input. Nothing to orchestrate, nothing that can fail in a retryable way | +| `requestApproval` | Workflow | Blocks for a human decision, holds the reminder count, and accepts messages while pending | +| `remindApprover` | Sync messaging (Signal) | A Signal to the approval started by `requestApproval` | +| `submitDecision` | Update | An Update to that same approval, because the caller needs a result back | +| `attachApprovalContext` | Sync messaging (Signal-with-Start) | A Signal that also starts the approval if it does not exist yet | +| `notifyRequester` | Standalone Activity | One outbound notification, no state, nothing to wait for | + +Update is its own backing, not a variation on Workflow: the Operation completes when the Update completes, not when the Workflow returns. Signals are not a backing at all. They are [sync messaging](/nexus/temporal-operation-handler#the-nexus-aware-client) — they take effect during the handler call, and the Operation completes inline the way `checkApprovalRequired` does. + +That choice stays private to the handler, but the caller can still observe it. A Signal-backed Operation completes synchronously, so a Temporal caller's Event History goes straight from `NexusOperationScheduled` to `NexusOperationCompleted`. An Update-backed Operation completes through the Nexus completion callback, which adds a `NexusOperationStarted` event carrying the Operation token used to cancel it. + +Three of these are worth the contrast. + +An approval has to be a Workflow. It exists for a while, it has identity, and other systems interact with it during its lifetime. Backing it with an Activity would not work at all — an Activity cannot block for a human and cannot receive a Signal. + +The notification is the opposite. It is a single side effect with nothing to orchestrate, so a Workflow would add an Event History and a Workflow Id for no benefit. But it does touch the outside world and can fail, so it needs an Activity rather than nothing. + +`checkApprovalRequired` is the case for no backing at all. Compare it against the notification: both are "one small thing," and they get opposite answers. Sending mail can fail and you want that retried with a record of each attempt. Comparing an amount to a threshold cannot fail in any way worth retrying, so an Activity Execution would be pure overhead. + +## Give the approval Workflow a stable Id + +The approval needs a Workflow Id derived from the purchase, not a random one, so that later messages can find it. Deriving it from the item id means a caller that knows the item id can reach the right Execution without the handler handing out Workflow Ids. + +This also makes the start idempotent. A retried Nexus start request targets the same Workflow Id rather than starting a second approval for one purchase. + +Deriving it in one place keeps the two Operations that need it from drifting apart: + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java) + +```java + public static String forItem(String itemId) { + return "approval-" + itemId; + } +``` + + + +It matters again in [step 8](/develop/java/nexus/development-walkthrough?step=send), where `attachApprovalContext` may start the approval before `requestApproval` is ever called. Both Operations derive the same Workflow Id from the same item id, which is what lets them agree on which Execution they mean. + +## Build the Operation that needs no backing + +`checkApprovalRequired` needs no Workflow, no Activity, and no Worker registration beyond the Service itself, so it is the shortest path to a working Operation. + +Implement it with `TemporalOperationHandler` like every other Operation, apply the threshold, and return a synchronous result. The Operation completes during the handler call. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler + checkApprovalRequired() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + TemporalOperationResult.sync( + new CheckApprovalRequiredOutput( + input.getAmount() >= APPROVAL_THRESHOLD, APPROVAL_THRESHOLD))); + } +``` + + + +### Why build it this way? + +Nothing about this choice is permanent, and that is the point of making it with `TemporalOperationHandler` rather than a plain synchronous handler. + +What backs an Operation is private to the handler, so it can change while the contract stays exactly where it is. If spend policy moves out of the handler and into a policy service, `checkApprovalRequired` becomes Activity-backed. If it grows into something with several steps — checking a budget, consulting delegation rules, waiting on a policy engine that is slow — it becomes Workflow-backed, the same shape as `requestApproval`. In every case the handler changes and no caller does, because the contract did not. + +That is the reason to reach for `TemporalOperationHandler` even for an Operation this small. It is the single entry point for all three backings, so replacing a threshold comparison with a full Workflow later is an edit inside one method rather than a new Operation and a contract change. + +Nothing runs this Operation yet. [Step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker) starts the Worker that hosts the Service, and [step 5](/develop/java/nexus/development-walkthrough?step=publish) makes it reachable, so this is the first Operation you will see respond once those are in place. + + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for Activity-backed Operations. +- [Workflow message passing](/encyclopedia/workflow-message-passing) for what makes a Workflow interactive. +- [Nexus patterns](/nexus/patterns) for Service and Worker topology choices. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/debugging-and-tips.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/debugging-and-tips.mdx new file mode 100644 index 0000000000..459a81c12d --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/debugging-and-tips.mdx @@ -0,0 +1,114 @@ +Most Nexus problems are wiring problems, and they produce a small number of recognizable symptoms. Work from the symptom. + +## Pre-release build errors + +Two failures mean the development server is missing a setting rather than your code being wrong. +Both settings are off by default and are covered in +[Before you start](/develop/java/nexus/development-walkthrough?step=overview#start-the-development-server). + +**`Standalone Nexus operation is disabled`** comes back from `temporal nexus operation execute`, +`start`, `describe`, and `result`. The server was started without +`nexusoperation.enableStandalone=true`, so a Client cannot start an Operation without a caller +Workflow. Operations called from a caller Workflow are unaffected, which is why this only shows up +when running Operations from the command line. + +**`completion callbacks are not enabled for this namespace`** fails the `notifyRequester` Operation +only. The server was started without `activity.enableCallbacks=true`, so a completion callback +cannot attach to a Standalone Activity Execution. The other five Operations are unaffected. + +A stock Temporal CLI also does not have `temporal nexus operation` at all. If those commands are not +in `temporal nexus --help`, you are on a released build rather than the pre-release one this +walkthrough needs. + +## The call hangs and nothing happens + +Three causes, in the order worth checking. + +**No Worker is polling the target Task Queue.** The request was accepted and queued, and nothing is serving it. Check that your handler Worker is running and shows as a poller on the Endpoint's target Task Queue. + +**The Task Queue does not match.** The Endpoint's target Task Queue and the Task Queue your Worker registered are two separate strings that have to be identical. A typo produces exactly this symptom, because the request is queued somewhere nobody is listening. + +**The timeout is longer than your patience.** A human approval with a multi-day schedule-to-close timeout is supposed to sit there. Confirm the Operation is actually pending rather than stuck by looking at it in the UI. + +## The call fails as unauthorized + +The caller Namespace is almost certainly not on the Endpoint's allowed caller list. + +Creating an Endpoint does not authorize anyone to call it. Endpoints reject callers that are not explicitly allowed, and in Temporal Cloud the Namespace name includes an Account suffix that is easy to omit. See [Nexus security](/nexus/security). + +## The caller and handler are not linked in the UI + +The handler constructed its own Temporal Client instead of using the one `TemporalOperationHandler` provides. + +Constructing a Client yourself works, and the Operation behaves correctly, but you lose the [bidirectional links](/nexus/execution-debugging#bi-directional-linking) that connect the two Executions. Use the [Client](/nexus/temporal-operation-handler#the-nexus-aware-client) your handler receives for anything that starts or messages an Execution. + +## The Operation fails because the Workflow already exists + +A Workflow-backed Operation starts a Workflow, and by default starting one whose Id is already running is an error. If two Operations derive the same Workflow Id — which is normal and usually intended — the second one fails. + +This is the behavior to expect, not a bug. A Workflow-backed Operation has only started successfully once its completion callback is attached, so failing beats reporting success to a caller that would then wait for a result nobody will deliver. + +When you want the second caller to join the Execution rather than fail, set the Workflow Id conflict policy to use-existing. See [When the approval already exists](/develop/java/nexus/development-walkthrough?step=send#when-the-approval-already-exists). + +## Pitfalls that are easy to miss + +### Polling for a result that is already being delivered + +The single most common design mistake in this shape. + +The approval's decision is the result of `requestApproval` — the Workflow's return value, pushed to whoever awaited the Operation the moment the Workflow completes. Asking the approval for its status in a loop means polling for something already on its way to you, and it stops working entirely once the approval completes and its [Retention Period](/temporal-service/temporal-server#retention-period) expires. + +Use the Operation result for outcomes. Use messages to change a running approval, not to read it. + +### Expecting a late caller to collect a finished result + +Whoever is attached to an Operation receives its result. A caller that shows up after the approval has completed has nothing to attach to. + +While the approval is still running, additional callers can attach with the use-existing conflict policy and all receive the same decision. Once it has completed, they cannot — so either attach before it finishes, or have the handler notify them, which is what `notifyRequester` does. + +### An Activity-backed Operation that will not cancel + +An Activity is not interrupted by cancellation the way a Workflow is. Without heartbeating from the Activity and a heartbeat timeout, a cancellation request has no effect and the Operation runs to its timeout. Let the resulting cancellation exception propagate: a cancelled Activity is not retried, but one that swallows the cancellation and throws an ordinary failure instead is. See [Activity cancellation](/activity-execution#cancellation). + +### Duplicate side effects on retry + +The server retries Nexus start requests. If the backing Execution's Id is not derived from something stable, a retry starts a second one. + +Derive the Workflow Id or Activity Id from the Nexus request Id, or from the Operation input when several Operations should share one Execution. This matters most for Operations with external side effects — a duplicate notification is a second message to a real person. + +### Sending a Signal to a Workflow that may not exist + +A Signal to a missing Workflow fails. Use Signal-with-Start when the target may not be running yet; it starts the Workflow if needed and delivers the Signal either way. Remember that its Operation input has to carry whatever the Workflow needs to start, not just the message. See [Attach information before the approval exists](/develop/java/nexus/development-walkthrough?step=send#attach-information-before-the-approval-exists). + +### More than one async backing per handler invocation + +A handler can perform unlimited sync side effects but at most one async backing. Starting a Workflow and starting a Workflow Update in the same invocation is not a valid Operation. Compose sync side effects freely; pick one thing for the caller to await. + +### Hand-editing generated code + +Generated files are marked as generated and are overwritten on the next run. When a generated name is wrong, fix it with a per-language naming override in the contract. See the [Nexus Client Code Generator](/develop/java/nexus/client-code-generator). + +### Letting the contract drift + +Callers and handlers deploy independently, so both sides run different contract versions simultaneously. Adding an optional field is safe. Making a field required, removing one, or changing a type is not — it breaks whichever side deploys second. + +## Tips + +**Verify the wiring before writing a caller.** Confirm the Endpoint exists, targets the right Namespace and Task Queue, and that a Worker is polling it. This eliminates most of the symptoms above before any caller code exists. + +**Set timeouts to match reality.** A human approval measured in days needs a schedule-to-close timeout in days. Defaults are not tuned for human latency. + +**Let contract violations be `BAD_REQUEST`.** The generated validators aggregate every violation into one error, so the caller learns everything that was wrong in one response instead of fixing fields one at a time. See [Nexus error handling](/nexus/error-handling). + +**Use `TemporalOperationHandler` even when the Operation is trivial.** An Operation that starts synchronous can later gain an async backing or a Signal without changing shape. + +Back to the [Microservice Development Walkthrough overview](/develop/java/nexus/development-walkthrough). + +:::tip RESOURCES + +- [Nexus execution debugging](/nexus/execution-debugging) for tracing Operations across Namespaces. +- [Nexus error handling](/nexus/error-handling) for the error model and retry behavior. +- [Nexus security](/nexus/security) for Endpoint authorization. +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for current per-SDK capability status. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/define-the-data-contract.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/define-the-data-contract.mdx new file mode 100644 index 0000000000..85b029f303 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/define-the-data-contract.mdx @@ -0,0 +1,241 @@ +:::caution Run the setup first + +This step assumes you read through the +[Overview](/develop/java/nexus/development-walkthrough?step=overview) and ran the three terminal commands there! + +::: + +Start with the contract, not the code. + +The contract is the only thing a caller and a handler share. Everything else — which language each side is written in, whether an Operation is backed by a Workflow or an Activity, which Task Queue the Worker polls — is private to one side and can change without the other side knowing. + +## Why the contract comes first + +Writing the contract first is what makes the Service polyglot. + +**Every sample in this walkthrough, in every language, is generated from this one contract.** A Go caller can call a Java handler. A TypeScript caller can call a Python handler. What language a side is written in has no bearing on whether the two can talk — the only thing that has to match is the contract they were both generated from. Each side picks whatever language suits it, and as long as both were built against this contract, they interoperate. + +That is why the contract comes before any implementation. It is written once, in no particular language, and every implementation in this walkthrough is generated from it. + +## Plan the Operations + +As with all API design, work backwards from what callers need, not from what your Workflow happens to do. + +For the approval problem, callers need to check whether a purchase needs approval at all, start an approval and learn its outcome, nudge a pending approval, attach supporting information to a purchase, submit a decision, and be notified when the decision is final. That produces six Operations: + +| Operation | Input | Output | Added in | +| --- | --- | --- | --- | +| `checkApprovalRequired` | Item id, requester, amount | Whether approval is needed, and the threshold applied | Step 3 | +| `requestApproval` | Item id, requester, amount | `APPROVED` or `DENIED` | Step 4 | +| `remindApprover` | Item id | Nothing | Step 7 | +| `submitDecision` | Item id, decision | The decision recorded, and how many reminders preceded it | Step 7 | +| `attachApprovalContext` | Item id, requester, amount, note | Nothing | Step 8 | +| `notifyRequester` | Requester, decision | Where the notification was delivered | Step 9 | + +Three of those are worth explaining now, because they are easy to get wrong. They also introduce the three shapes an Operation can take, named here and chosen per Operation in [step 3](/develop/java/nexus/development-walkthrough?step=backing): + +**`checkApprovalRequired` answers a question without starting anything.** A small purchase may not need approval, and finding that out should not create an approval, a Workflow, or any durable record. This is the synchronous case: the Operation applies a spend threshold and returns the answer during the call, so a caller can skip the rest of this Service entirely. + +**`requestApproval` returns the final decision.** It does not return an approval id for the caller to poll. The Operation is Workflow-backed, so it completes when that Workflow returns, and the Workflow's return value *is* the Operation's result. The caller awaits the Operation and receives `APPROVED` or `DENIED`. + +**`attachApprovalContext` does not require the approval to exist.** Supporting information — a justification, a link to a quote, a manager's note — is produced by a different system than the one requesting approval, and the two messages can arrive in either order. The Operation is written so that either order works, which means both might have to start the approval workflow. Since this message might have to start the workflow, its input needs to include the purchase details so that the workflow has enough information to start. [Step 8](/develop/java/nexus/development-walkthrough?step=send#attach-information-before-the-approval-exists) covers this in detail. + +## Contract design rules + +An Operation's input and output are each optional, but when present each must be an **object type**. If the input is only a single variable a class wrapping that is still required. This allows you to add a field later without breaking the wire format. Conversely, though, returning nothing at all is fine, which `remindApprover` does. + +Keep the types **forward-compatible** if you change the contract. Callers and handlers deploy independently and will run different versions of the contract at the same time. Adding an optional field is safe; making an existing field required, or removing one, is not. + +## Write the contract + +Contracts are modeled with JSON Schema 2020-12. Each definition file is one of two kinds, decided by what sits at its root: + +- **Nexus document** - the root carries a `nexusrpc: '1.0.0'` marker and acts as an envelope, with Services and their Operations at the top level and types under `$defs`. Only this kind can declare a Service. +- **Pure JSON Schema** - the root is itself a type, with reusable types under `$defs`. No Service or Operation declarations, just data models shared across languages. + +A file is one or the other, never both. A contract can span several files, with a Nexus document pulling in types from pure-schema files through `$ref`. + +The approval contract declares a Service with six Operations, so its entry file is a Nexus document. + +A Nexus document has two sections. `services` declares the Services and their Operations, with each +Operation naming its input and output by reference. `$defs` declares the reusable types those +references point at, in plain JSON Schema. Read the contract below in that order: what the Service +offers first, then the shape of the data it moves. + +Nothing under `$defs` is Nexus-specific. It is ordinary JSON Schema, which is what lets the same +types generate into four languages. + +:::note How to write a contract of your own + +This walkthrough hands you a finished contract. To write one yourself, start with +[Definition files](/develop/java/nexus/client-code-generator#definition-files), which documents both +flavors in full, with the supported subset of JSON Schema and a worked example to model this contract +on. For the complete rules the generator enforces, see +[Definition files](https://github.com/temporalio/nexgen#definition-files) in the `nexgen` README. + +In [step 2](/develop/java/nexus/development-walkthrough?step=generate) you pass this file to `nexgen`, +which reads the contract and generates the Java client and handler code from it. + +::: + +Here is the approval contract in full. Every Operation the walkthrough builds is declared here, before any implementation exists: + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml) + +```yaml +nexusrpc: "1.0.0" +$schema: https://json-schema.org/draft/2020-12/schema +description: Purchase approval service built by the Nexus Microservice Development Walkthrough. + +services: + ApprovalService: + fqn: temporal.samples.approval.v1.ApprovalService + description: Start a purchase approval, message it while it is pending, and learn the outcome. + operations: + + # Answers a question without starting anything. Backed by nothing at all - see step 3. + checkApprovalRequired: + description: Report whether a purchase needs approval, before any durable work starts. + input: { $ref: "#/$defs/CheckApprovalRequiredInput" } + output: { $ref: "#/$defs/CheckApprovalRequiredOutput" } + + # Backed by a Workflow. The Workflow's return value is this Operation's result - see step 4. + requestApproval: + description: Start an approval and return the decision once it is made. + input: { $ref: "#/$defs/RequestApprovalInput" } + output: { $ref: "#/$defs/RequestApprovalOutput" } + + # A Signal. Fire-and-forget, so it declares no output - see step 7. + remindApprover: + description: Ask the approver again. Returns nothing. + input: { $ref: "#/$defs/RemindApproverInput" } + + # An Update. The caller needs confirmation back, which is what makes this an Update rather + # than a Signal - see step 7. + submitDecision: + description: Supply the decision and confirm it was recorded. + input: { $ref: "#/$defs/SubmitDecisionInput" } + output: { $ref: "#/$defs/SubmitDecisionOutput" } + + # Signal-with-Start. Its input repeats the purchase details because it may have to create the + # approval it is messaging - see step 8. + attachApprovalContext: + description: Attach supporting information to a purchase, whether or not its approval exists yet. + input: { $ref: "#/$defs/AttachApprovalContextInput" } + + # Backed by a Standalone Activity - one durable step, no Workflow - see step 9. + notifyRequester: + description: Notify the requester once the decision is final. + input: { $ref: "#/$defs/NotifyRequesterInput" } + output: { $ref: "#/$defs/NotifyRequesterOutput" } + +# The APPROVED | DENIED value set is declared inline on each property that carries it, rather than +# once under $defs. A named enum under $defs is rejected by the generator today, so each Operation +# gets its own nested value class; handler/Decisions.java converts between them. +$defs: + + CheckApprovalRequiredInput: + type: object + additionalProperties: false + properties: + itemId: { description: Identifier of the purchase., type: string } + requester: { description: Who is asking., type: string } + amount: { description: Purchase amount., type: number } + required: [itemId, requester, amount] + + CheckApprovalRequiredOutput: + type: object + additionalProperties: false + properties: + approvalRequired: { description: Whether an approval has to be started., type: boolean } + threshold: { description: The spend threshold that was applied., type: number } + required: [approvalRequired, threshold] + + RequestApprovalInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + requester: { type: string } + amount: { type: number } + required: [itemId, requester, amount] + + RequestApprovalOutput: + type: object + additionalProperties: false + properties: + decision: + description: The outcome of the approval. + type: string + enum: [APPROVED, DENIED] + required: [decision] + + RemindApproverInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + required: [itemId] + + SubmitDecisionInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + decision: + description: The decision being submitted. + type: string + enum: [APPROVED, DENIED] + required: [itemId, decision] + + SubmitDecisionOutput: + type: object + additionalProperties: false + properties: + recorded: + description: The decision that was recorded. + type: string + enum: [APPROVED, DENIED] + remindersSent: { description: How many reminders were sent before the decision., type: integer } + required: [recorded, remindersSent] + + AttachApprovalContextInput: + type: object + additionalProperties: false + properties: + itemId: { type: string } + requester: { type: string } + amount: { type: number } + note: { description: The supporting information to attach., type: string } + required: [itemId, requester, amount, note] + + NotifyRequesterInput: + type: object + additionalProperties: false + properties: + requester: { type: string } + decision: + description: The final decision. + type: string + enum: [APPROVED, DENIED] + required: [requester, decision] + + NotifyRequesterOutput: + type: object + additionalProperties: false + properties: + deliveredTo: { description: Where the notification was sent., type: string } + required: [deliveredTo] +``` + + + + +:::tip RESOURCES + +- [Nexus Client Code Generator](/develop/java/nexus/client-code-generator) for the contract format and the supported JSON Schema subset. +- [Nexus Services](/nexus/services) for what a Service contract is and how it is shared. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/generate-code.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/generate-code.mdx new file mode 100644 index 0000000000..f883368048 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/generate-code.mdx @@ -0,0 +1,72 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +Generate the library code from the contract before writing any implementation. Both sides of the Service use it: the handler implements against the generated Service definition, and the caller invokes against the same one. + +## What generation produces + +For each type in the contract, the [Nexus Client Code Generator](/develop/java/nexus/client-code-generator) emits a typed model and a runtime validator. If the contract declares Services, it will also create a Nexus Service definition. + +The generated Service definition carries one member per Operation, in whatever form is idiomatic for the language. It is used on both sides and in two different ways: + +- The **handler** provides an implementation for it, which the Worker registers. +- The **caller** uses it to invoke Operations, so calls are type-checked against the contract. + +`nexgen` generates Go, Java, Python, and TypeScript, which is the set of languages this walkthrough covers. + +The generated validators run when a payload is parsed and again when it is serialized, so a request that violates the contract is rejected at the boundary rather than reaching your Workflow. Violations aggregate into one error naming every field that failed, which a handler maps to `BAD_REQUEST`. + +## Generate the code + +Generation is one command per language, with a few per-language flags — Java, for instance, requires a package name whose last segment matches the output directory name. + +**[Generate code](/develop/java/nexus/client-code-generator#generate-code)** has the full command shape and the flags each language takes, with examples for each. + +The command below is the one for this walkthrough, and it is written for the `samples-java` clone +you made in [Before you start](/develop/java/nexus/development-walkthrough?step=overview#clone-the-sample-project). +Run it from the repository root. Its contract is +[approval.nexusrpc.yaml](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml), +the same file shown in [step 1](/develop/java/nexus/development-walkthrough?step=contract#write-the-contract). + +:::caution For reviewers + +The two links above, and the code samples throughout this walkthrough, point at a sample project +that is not on GitHub yet. They will not resolve until the `samples-java` pull request is merged. + +::: + + + +```bash title="Run: generate the Java code" +nexgen java \ + --output core/src/main/java/io/temporal/samples/nexuswalkthrough/generatedservice \ + --package-name io.temporal.samples.nexuswalkthrough.generatedservice \ + core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml +``` + + + +Only one thing in those paths is a requirement. **Java requires the package name's last segment to +match the output directory's name**, which is why both end in `generatedservice`. Everything else +is this repository's layout: the `core/` module, the `io.temporal.samples.nexuswalkthrough` package, +and the contract sitting next to the code that uses it. In your own project, point `--output` and +`--package-name` wherever your source tree keeps generated code, and pass the path to your own +contract. + +One constraint does carry over. Generation **clears the output directory**, so keep the contract +outside it. A schema stored in the output directory is deleted the first time you regenerate. + +Commit the generated code, and regenerate whenever the contract changes. Do not hand-edit it — the files are marked as generated, and your edits are lost on the next run. When a generated name is wrong for your language, fix it in the contract with a per-language naming override rather than editing the output. See the **[Nexus Client Code Generator](/develop/java/nexus/client-code-generator)** for more details. + +## One contract, four languages + +Run the generator once per language and you have that language's contract code — typed models, validators, and the Service definition. That is not a working handler or caller; you still write those. It is the part both sides have to agree on, generated from one source instead of hand-written twice, and the same generated code serves whichever side you are building. Nothing about a handler needs to know which languages its callers use, and nothing about a caller needs to know which language implements the handler. + +This is the step where the contract-first ordering pays off, and it is what makes the cross-language call in [step 6](/develop/java/nexus/development-walkthrough?step=call) work without any coordination beyond the contract. + + +:::tip RESOURCES + +- [Nexus Client Code Generator](/develop/java/nexus/client-code-generator) for installation, per-language commands, and the supported JSON Schema subset. +- [Use the generated code](/develop/java/nexus/client-code-generator#use-the-generated-code) for how validation is wired in each language. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/implement-the-service.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/implement-the-service.mdx new file mode 100644 index 0000000000..e86fffc468 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/implement-the-service.mdx @@ -0,0 +1,280 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +[Step 3](/develop/java/nexus/development-walkthrough?step=backing#build-the-operation-that-needs-no-backing) implemented the one Operation that needs no backing. This step adds the one at the center of the Service: back `requestApproval` with the approval Workflow, then run a Worker that hosts the Service, the Workflow, and the Activities. + +## Write the approval Workflow + +The Workflow is an ordinary Temporal Workflow — the interactive one chosen in [step 3](/develop/java/nexus/development-walkthrough?step=backing#workflow). Nothing in it is Nexus-specific, and it could be started directly by a Client instead. What makes it interactive is the message handlers added in [step 7](/develop/java/nexus/development-walkthrough?step=messaging) and a Workflow Id you can predict. + +For the approval, it needs to: + +1. Run an Activity that evaluates whether the request can be auto-decided. In this walkthrough it is a placeholder that does nothing — real logic would apply policy, check limits, or call a risk service. +2. Run an Activity that tells a human the request is waiting. Also a placeholder. +3. Block until a decision arrives. +4. Return `APPROVED` or `DENIED`. + +The blocking step is the reason this is a Workflow. It may wait weeks, across Worker restarts and deployments, and the wait costs nothing while it is idle. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowImpl.java) + +```java +public class ApprovalWorkflowImpl implements ApprovalWorkflow { + + private static final Logger logger = Workflow.getLogger(ApprovalWorkflowImpl.class); + + private final ApprovalActivities activities = + Workflow.newActivityStub( + ApprovalActivities.class, + ActivityOptions.newBuilder().setStartToCloseTimeout(Duration.ofSeconds(10)).build()); + + // Durable intermediate state. This is the other reason the approval is a Workflow: an Activity + // could not hold any of it. + private SubmitDecisionInput.Decision decision; + private int remindersSent; + private final List notes = new ArrayList<>(); + + @Override + public RequestApprovalOutput runApproval(RequestApprovalInput input) { + // Placeholder. Real logic would apply policy, check limits, or call a risk service. + activities.evaluateAutoDecision(input.getItemId(), input.getAmount()); + + // Placeholder. Real logic would page an approver, open a ticket, or send email. + activities.notifyApproverOfPendingRequest(input.getItemId(), input.getRequester()); + + // Block until submitDecision supplies a decision. This wait is durable and unbounded - the + // Worker can restart and redeploy while it is pending. + Workflow.await(() -> decision != null); + + logger.info( + "Approval for {} decided {} after {} reminder(s) and {} note(s)", + input.getItemId(), + decision.getValue(), + remindersSent, + notes.size()); + + // This return value becomes the result of the requestApproval Nexus Operation, delivered to + // every caller whose completion callback is attached to this Execution. + return new RequestApprovalOutput(Decisions.toRequestApprovalOutput(decision)); + } + + // STEP 7 - Signal handler. Records the nudge and returns nothing. + @Override + public void remindApprover() { + remindersSent++; + logger.info("Approver reminded, {} reminder(s) so far", remindersSent); + } + + // STEP 8 - Signal handler reached through Signal-with-Start. When the note arrives before anyone + // has called requestApproval, the Signal-with-Start creates this Workflow and this handler runs + // on the fresh Execution. + @Override + public void attachContext(String note) { + notes.add(note); + logger.info("Context attached: {}", note); + } + + // STEP 7 - The Update's validator. Runs before the handler and can reject the request without + // changing anything or writing to Event History. Throwing here rejects the Update; the Workflow + // is untouched and the caller's Operation fails. + @Override + public void validateSubmitDecision(SubmitDecisionInput.Decision decision) { + if (this.decision != null) { + throw new IllegalStateException( + "approval already decided " + this.decision.getValue() + ", cannot decide again"); + } + } + + // STEP 7 - Update handler. Records the decision, which satisfies the condition the Workflow + // method is blocked on, and returns confirmation to the caller. The validator above guarantees + // this runs at most once. + @Override + public SubmitDecisionOutput submitDecision(SubmitDecisionInput.Decision decision) { + this.decision = decision; + return new SubmitDecisionOutput(Decisions.toSubmitDecisionOutput(decision), remindersSent); + } +} +``` + + + +## Implement the Operation with TemporalOperationHandler + +The Operation is what calls the Workflow. + +Use `TemporalOperationHandler` for every Temporal-backed Operation, including simple ones. It is the entry point to the [Temporal Operation Handler](/nexus/temporal-operation-handler) programming model, and starting with it means an Operation can later gain a Signal or change its backing without changing shape. + +`TemporalOperationHandler.create(...)` gives your start handler a context, a Nexus-aware Client, and the Operation input. Call `startWorkflow` on that Client and return its result. The Operation then completes when the Workflow returns, delivering the Workflow's return value to the caller. + +The Client is not an ordinary Temporal Client. It propagates bidirectional links and request Ids automatically, so the caller's Execution and the approval Workflow are connected in the UI without you wiring anything. Fetching your own Client inside a handler works, and the Operation behaves correctly, but you give up that linking. This is the single biggest reason to use the injected Client for anything that starts or messages an Execution. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler requestApproval() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startWorkflow( + ApprovalWorkflow.class, + ApprovalWorkflow::runApproval, + input, + WorkflowOptions.newBuilder() + .setWorkflowId(ApprovalWorkflowId.forItem(input.getItemId())) + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING) + .build())); + } +``` + + + +Set the Workflow Id from the item id, as decided in [step 3](/develop/java/nexus/development-walkthrough?step=backing#give-the-approval-workflow-a-stable-id). + +By default, starting a Workflow whose Id is already running **fails the Operation**. That is the right default here: the Operation has only started successfully once its completion callback is attached to a Workflow, so failing loudly beats reporting success to a caller that would then wait forever. [Step 8](/develop/java/nexus/development-walkthrough?step=send#when-the-approval-already-exists) revisits this option, because once another Operation can create the approval first, this Operation needs to attach to it instead of failing. + +## Run the Worker + +One Worker hosts the Nexus Service implementation, the Workflow implementation, and the Activity implementations. Its Task Queue has to match the Task Queue the Nexus Endpoint targets, which you create in the next step. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/HandlerWorker.java) + +```java + public static void main(String[] args) { + WorkflowClient client = ClientOptions.getWorkflowClient(args); + + WorkerFactory factory = WorkerFactory.newInstance(client); + Worker worker = factory.newWorker(DEFAULT_TASK_QUEUE_NAME); + + worker.registerWorkflowImplementationTypes(ApprovalWorkflowImpl.class); + worker.registerActivitiesImplementations(new ApprovalActivitiesImpl()); + worker.registerNexusServiceImplementation(new ApprovalServiceImpl()); + + factory.start(); + } +``` + + + +A Worker registering a Nexus Service does not need to be the same Worker that runs the backing Workflow. Splitting them is a normal choice for larger deployments — see [Nexus patterns](/nexus/patterns). + +Start it from the repository root, and leave it running: + + + +```bash title="Run 1 of 4: start the handler Worker" +./gradlew -q :core:execute \ + -PmainClass=io.temporal.samples.nexuswalkthrough.handler.HandlerWorker \ + --args="-target-host localhost:7233 -namespace approval-handler-namespace" +``` + + + +Qualify the task as `:core:execute`. An unqualified `execute` also runs the `lambda-worker:starter` +task, which ignores `-PmainClass` and starts an unrelated sample. Like any Worker this one runs +until you stop it, so Gradle keeps reporting the task as executing. + +## Run the Operations + +The Service is now reachable through the Endpoint you created in +[Before you start](/develop/java/nexus/development-walkthrough?step=overview#create-the-namespaces-and-the-endpoint), +so both Operations written so far can be run from the command line. No caller Workflow and no caller +Worker exist yet, and none are needed: `temporal nexus operation execute` starts a +[Standalone Nexus Operation](/standalone-nexus-operation), which makes the CLI the caller. + +Start with `checkApprovalRequired` from [step 3](/develop/java/nexus/development-walkthrough?step=backing#build-the-operation-that-needs-no-backing). +The sample sets the spend threshold at 500, so this amount is over it: + + + +```bash title="Run 2 of 4: check whether approval is required" +temporal nexus operation execute \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation CheckApprovalRequired \ + --operation-id check-1 \ + --input '{"itemId":"laptop-42","requester":"tao","amount":2500}' +``` + + + +``` +Results: + Status COMPLETED + Result {"approvalRequired":true,"threshold":500} +``` + +The Operation names on the command line are `CheckApprovalRequired` and `RequestApproval`, not the +`checkApprovalRequired` and `requestApproval` keys in the contract. The generator emits the wire +name in Pascal case, and that is the name the Endpoint routes on. + +Now `requestApproval`. It is Workflow-backed and the approval blocks for a decision, so it does not +return during the call. Use `start` rather than `execute`, which returns as soon as the Operation +has started: + + + +```bash title="Run 3 of 4: request an approval" +temporal nexus operation start \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation RequestApproval \ + --operation-id approval-1 \ + --input '{"itemId":"laptop-42","requester":"tao","amount":2500}' +``` + + + +`describe` shows it sitting open, which is the durability the Workflow backing buys you: + + + +```bash title="Run 4 of 4: describe the pending Operation" +temporal nexus operation describe \ + --namespace approval-caller-namespace --operation-id approval-1 +``` + + + +``` + Operation RequestApproval + Status Running + State Started + OperationToken eyJ0IjoxLCJucyI6ImFwcHJvdmFsLWhhbmRsZXItbmFtZXNwYWNlIiwid2lkIjoiYXBwcm92YWwtbGFwdG9wLTQyIn0 + +Links: 1 + + Link temporal:///namespaces/approval-handler-namespace/workflows/approval-laptop-42/... +``` + +Two things in that output are worth reading. The Workflow Id is `approval-laptop-42`, derived from +the item id as decided in [step 3](/develop/java/nexus/development-walkthrough?step=backing#give-the-approval-workflow-a-stable-id). +And the link points from the Operation to the approval Workflow in the handler Namespace, which the +Nexus-aware Client attached without any code to do it. + +The approval stays open until something decides it. [Step 7](/develop/java/nexus/development-walkthrough?step=messaging) +adds the Operation that does, and closes this one out. + +## Handle failures + +Two failure categories behave differently, and callers can tell them apart. + +A **contract violation** — a payload the generated validator rejects — should surface as `BAD_REQUEST`. It is the caller's fault and retrying will not help. The generated validators aggregate every violation into one error, so the caller learns everything that was wrong in a single response. + +An **application failure** — the approval cannot proceed for a business reason — is a failed Operation. Whether it retries depends on the error type you raise. See [Nexus error handling](/nexus/error-handling). + + +:::tip RESOURCES + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the handler type and the Nexus-aware Client. +- [Java Nexus feature guide](/develop/java/nexus/feature-guide) for the full handler and Worker API. +- [Nexus error handling](/nexus/error-handling) for mapping failures to Nexus errors. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/overview.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/overview.mdx new file mode 100644 index 0000000000..c0f1382618 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/overview.mdx @@ -0,0 +1,174 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +:::caution + +This walkthrough covers the [Temporal Operation Handler](/nexus/temporal-operation-handler), which is pre-release. +APIs are experimental and may change in backwards-incompatible ways. + +::: + +This walkthrough builds one Nexus Service from nothing to a complete API, adding a single Nexus capability at each step. + +A [Nexus Service](/evaluate/nexus) is a contract that one team publishes and other teams call, across [Namespace](/namespaces) boundaries, without sharing code or a deployment. + +## The walkthrough problem + +This guide creates a purchase approval Workflow — a common Temporal and Nexus use case. + +**A purchase request needs approval before it can proceed.** + +Approval is slow and human-driven: someone has to look at the request and decide. The system needs to survive that wait, which may be minutes or weeks. While a request is pending, other systems might need to nudge the approver or attach information to the request. Eventually a decision arrives, and the requesting system needs the outcome. + +Concretely, the Service needs to: + +- Tell a caller whether a purchase needs approval at all, before any durable work starts +- Start an approval and, eventually, return `APPROVED` or `DENIED` +- Accept a nudge that asks the approver again, and count how many have been sent +- Accept supporting information for a purchase, whether or not its approval exists yet +- Accept a decision from the caller and confirm it was recorded +- Send a notification when the decision is final + +Each of those needs a different Nexus capability, introduced one step at a time. + +## One contract, every language + +**This walkthrough builds the Service in Java.** The same contract has a sample implementation in every language the generator supports. The reasoning at each step — what the contract should say, what backs each Operation, which message type to reach for — is the same in all of them. + +Working sample code, all built from the one contract: + +| Language | Sample | +| --- | --- | +| Java (this walkthrough) | [nexuswalkthrough](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/nexuswalkthrough) | +| Go | `{sample repo link}` | +| Python | `{sample repo link}` | +| TypeScript | `{sample repo link}` | + +**Any caller can call any handler, because the contract is the only thing the two sides share.** A Go caller can drive the Python handler; the TypeScript caller can drive the Java handler. [Step 6](/develop/java/nexus/development-walkthrough?step=call) builds the Java caller and then points at the other languages' samples. + +:::note + +Sample repos for each language will land once the docs settle. The idea is that you can run the client from any sample against the handler from any other sample. + +::: + +The [Nexus Client Code Generator](/develop/java/nexus/client-code-generator) takes the contract and emits typed models, validators, and Service definitions for Go, Java, Python, and TypeScript. + +## How to follow along + +Two kinds of code block appear in this walkthrough. Mousing over either will give you a copy icon to the right of the code block. + +**A terminal window is a command to run.** This one is worth running now, to confirm you have a +pre-release CLI. Make sure the server version is 1.32.0 or higher: + + + +```bash title="Run: check your CLI version" +temporal --version +``` + + + +**Every terminal command has to be run, in order**, or later steps may fail. Each step that adds +an Operation also ends with a terminal window that exercises what you just built, so you get a +result at the end of every step rather than after five steps of unverified work. + +**A block headed by a file path is sample code**, shown so you can read it. Nothing needs to be +typed as this will be present in the [sample codebase](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/nexuswalkthrough). +The file name will a link to the file in the sample repo. + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalWorkflowId.java) + +```java + public static String forItem(String itemId) { + return "approval-" + itemId; + } +``` + +Output a command produces is shown in a plain block with no header, like this: + +``` +temporal version 1.8.3-server-1.32.0-162.0 (Server 1.32.0-162.0, UI 2.53.1) +``` + +## Before you start + +### Clone the sample project + +This walkthrough runs inside a clone of the [samples-java](https://github.com/temporalio/samples-java) +repository. Every command and file path below is relative to the repository root, and the finished +Service lives in +[core/src/main/java/io/temporal/samples/nexuswalkthrough](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/nexuswalkthrough). + + + +```bash title="Run 1 of 3: clone the sample" +git clone https://github.com/temporalio/samples-java.git +cd samples-java +``` + + + +The sample is the finished Service, so you can view and run the code in the repository to follow +along with the guide. Each step shows the code it is introducing and then runs it. + +### Use the pre-release CLI + +The Temporal Operation Handler is pre-release, so you need a pre-release [Temporal CLI](/cli) and +the development server that ships with it which can be [downloaded here](https://github.com/temporalio/cli/releases). +Check the what's changed section and ensure it is not a backport! A stock build currently rejects the Operations this walkthrough +runs. See [Debugging and tips](/develop/java/nexus/development-walkthrough?step=tips) +for what each failure looks like. + +### Start the development server + +Two dynamic config values are required: + + + +```bash title="Run 2 of 3: start the development server" +temporal server start-dev \ + --dynamic-config-value 'nexusoperation.enableStandalone=true' \ + --dynamic-config-value 'activity.enableCallbacks=true' +``` + + + +`nexusoperation.enableStandalone` allows a Client to start an Operation without a caller Workflow, +which is how you run each Operation from the command line as you build it. +`activity.enableCallbacks` allows a completion callback on a Standalone Activity Execution, which +the `notifyRequester` Operation in [step 9](/develop/java/nexus/development-walkthrough?step=activity) +needs. + +### Create the Namespaces and the Endpoint + +Create these in a second terminal. The handler and the caller each get their own Namespace, so the walkthrough +crosses a real Namespace boundary. + + + +```bash title="Run 3 of 3: create the Namespaces and the Endpoint" +temporal operator namespace create --namespace approval-handler-namespace +temporal operator namespace create --namespace approval-caller-namespace + +temporal operator nexus endpoint create \ + --name approval-endpoint \ + --target-namespace approval-handler-namespace \ + --target-task-queue approval-handler-task-queue \ + --description-file ./core/src/main/java/io/temporal/samples/nexuswalkthrough/description.md +``` + + + +[Steo 5](/develop/java/nexus/development-walkthrough?step=publish) explains what the Endpoint does +and covers Temporal Cloud, where the same Endpoint also needs an allowed-caller list. Creating it +now means every Operation is callable the moment you write it. + +### Run an Operation at the end of each step + +Each step that adds an Operation ends by running it, so you get a result before moving on rather +than after five steps of unverified work. Those runs use `temporal nexus operation execute`, which +starts a [Standalone Nexus Operation](/standalone-nexus-operation): the CLI is the caller, so no +caller Workflow and no caller Worker are needed until +[step 6](/develop/java/nexus/development-walkthrough?step=call). + +New to Nexus? Read [Nexus Services](/nexus/services) and [Nexus Operations](/nexus/operations), or work through the shorter [Java Nexus quickstart](/develop/java/nexus/quickstart). diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/publish-in-nexus.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/publish-in-nexus.mdx new file mode 100644 index 0000000000..1fedf9bf96 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/publish-in-nexus.mdx @@ -0,0 +1,92 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +The Service is implemented and a Worker is polling, but no caller can reach it. A [Nexus Endpoint](/nexus/endpoints) is what makes it reachable, and the [Nexus Registry](/nexus/registry) is where Endpoints live. + +An Endpoint routes incoming Operation requests to a target Namespace and Task Queue. Callers address the Endpoint by name and never learn the Namespace or Task Queue behind it, which is what lets you move the handler later without changing caller code. + +## Create the Endpoint + +An Endpoint needs three things: a unique name, the target Namespace where the handler runs, and the target Task Queue the handler's Worker polls. The Task Queue must match what your Worker registered in [step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker), or requests arrive and nothing picks them up. + +On a development server, create it with the CLI. This is the command already run in +[Before you start](/develop/java/nexus/development-walkthrough?step=overview#create-the-namespaces-and-the-endpoint), +repeated here with the parts explained: + +```bash +temporal operator nexus endpoint create \ + --name approval-endpoint \ + --target-namespace approval-handler-namespace \ + --target-task-queue approval-handler-task-queue \ + --description-file ./core/src/main/java/io/temporal/samples/nexuswalkthrough/description.md +``` + +`--description-file` is optional. It attaches Markdown shown to anyone browsing the Registry, which +is where you say what the Service is for and who owns it. + +In Temporal Cloud, create it in the UI under Nexus, or with `tcld`. See [Create a Nexus Endpoint](/nexus/registry#create-a-nexus-endpoint). + +Endpoint names are unique within the Registry. In Temporal Cloud the Registry is global across your whole Account and spans every Namespace; in a self-hosted deployment it is scoped to the Cluster. + +## Allow caller Namespaces + +:::note This section applies to Temporal Cloud only + +Endpoint caller authorization is a Temporal Cloud feature. If you are working through this walkthrough against a development server or a self-hosted Temporal Service on your own machine, skip to [Verify it is reachable](#verify-it-is-reachable) — there is no allowed-caller list to configure. + +::: + +In Temporal Cloud this is the step people miss, because the failure looks like a routing problem rather than a permissions one. + +An Endpoint **rejects callers that are not on its allowed list**. Creating the Endpoint is not enough — you have to name the Namespaces permitted to call it. The caller Namespace in this walkthrough is separate from the handler Namespace, so it has to be added explicitly. + +Set the allowed caller Namespaces when you create or edit the Endpoint in the UI, or with `tcld`. Add the caller Namespace, including its Account suffix. + +If a call fails as unauthorized and the Endpoint clearly exists, check this list first. + +## Set up credentials + +On a development server there is nothing to configure. Both Namespaces are local and unauthenticated. + +For Temporal Cloud, the caller and handler connect as separate clients, each to its own Namespace. Generate an API key with access to both Namespaces, or use mTLS certificates. The SDK's [environment configuration](/develop/environment-configuration) support lets you keep one profile per Namespace and select between them with an environment variable, which is cleaner than passing connection options in code. + +## Verify it is reachable + +Before writing a caller, confirm the wiring independently. Check that the Endpoint exists in the +Registry and targets the right Namespace and Task Queue: + + + +```bash title="Run 1 of 2: check the Endpoint exists" +temporal operator nexus endpoint get --name approval-endpoint +``` + + + +Then check that your handler Worker is polling that Task Queue: + + + +```bash title="Run 2 of 2: check the Worker is polling" +temporal task-queue describe \ + --namespace approval-handler-namespace \ + --task-queue approval-handler-task-queue +``` + + + +A Worker that is not polling is the other common cause of a call that appears to hang: the request is accepted and queued, and nothing serves it. + +You have already exercised the Endpoint end to end in +[step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-operations), so if +those Operations returned, the wiring above is already good. Come back to these commands when a +later call stops working. + + +:::tip RESOURCES + +- [Nexus Endpoints](/nexus/endpoints) and [Nexus Registry](/nexus/registry) for the concepts and management surfaces. +- [Nexus security](/nexus/security) for the Endpoint authorization model. +- [Temporal Cloud Nexus](/cloud/nexus) for Cloud-specific setup and limits. +- [Environment configuration](/develop/environment-configuration) for managing two Namespace profiles. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/_steps/send-messages.mdx b/docs/develop/java/nexus/development-walkthrough/_steps/send-messages.mdx new file mode 100644 index 0000000000..44d3f78c28 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/send-messages.mdx @@ -0,0 +1,196 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +From the caller's side, the messaging Operations are just Operations. They are called through the same generated stub as `requestApproval`, with the same type checking. + +The caller does not know that one is a Signal and one is an Update. That is the handler's implementation detail, and it can change without breaking callers. + +## Nudge and decide + +The caller calls both Operations exactly like any other, as shown in the caller Workflow in [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow). `remindApprover` returns nothing, so there is nothing to assign; `submitDecision` returns the confirmation the Update produced. + +What differs between them is what you get back and how long it takes. + +`remindApprover` returns nothing and completes as soon as the Signal is accepted. Accepted is not the same as handled — the Signal is durably recorded and the Workflow will process it, but the Operation does not wait for that. If the caller needs confirmation that the nudge took effect, it needs an Update, not a Signal. + +`submitDecision` returns confirmation that the decision was recorded. This is the point of using an Update: the caller learns the outcome of its own message. Once it succeeds, the approval Workflow unblocks and completes, which resolves the `requestApproval` Operation that the original caller is still awaiting. + +Both require the approval to already be running. A nudge or a decision for a purchase nobody has requested approval for has nothing to reach, and the Operation fails. + +## Attach information before the approval exists + +The next Operation does not have that requirement, and the reason is worth the detail. + +Supporting information for a purchase — a justification, a link to a quote, a manager's note — comes from a different system than the one requesting approval. Those two systems run independently, so their messages arrive in whatever order the network and their schedules produce. Sometimes the context arrives first. + +A plain Signal cannot handle that. Sending one to a Workflow that does not exist fails, and "fail if the approval has not been requested yet" is the wrong behavior for a message whose whole job is to be available whenever it shows up. + +`attachApprovalContext` uses **Signal-with-Start** instead. If the approval is already running, the note is delivered to it. If it is not, the approval is started and then the note is delivered. Either order works, and the caller does not have to know which happened. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler attachApprovalContext() { + return TemporalOperationHandler.create( + (ctx, client, input) -> { + WorkflowClient workflowClient = client.getWorkflowClient(); + ApprovalWorkflow stub = + workflowClient.newWorkflowStub( + ApprovalWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId(ApprovalWorkflowId.forItem(input.getItemId())) + .setTaskQueue(HandlerWorker.DEFAULT_TASK_QUEUE_NAME) + .build()); + + // signalWithStart delivers the Signal, starting the Workflow first if it is not already + // running. When the approval already exists, only the Signal is delivered. + BatchRequest request = workflowClient.newSignalWithStartRequest(); + request.add(stub::attachContext, input.getNote()); + request.add( + stub::runApproval, + new RequestApprovalInput(input.getItemId(), input.getRequester(), input.getAmount())); + workflowClient.signalWithStart(request); + + return TemporalOperationResult.sync(null); + }); + } +``` + + + +Signal-with-Start is sync messaging on the [Client](/nexus/temporal-operation-handler#the-nexus-aware-client), so the Operation completes during the handler call and returns nothing. The caller gets no confirmation that a human read the note, only that it was durably attached. + +### Input needs enough to start the Workflow + +Look at the contract from [step 1](/develop/java/nexus/development-walkthrough?step=contract#plan-the-operations) and `attachApprovalContext` carries more than it seems to need: the item id, the requester, the amount, *and* the note. `remindApprover` gets by with just the item id. + +That is a direct consequence of Signal-with-Start. The Operation might have to start the approval Workflow, and starting it requires whatever the Workflow needs to run. An Operation that can create the thing it messages has to carry enough input to create it. + +This is the general rule for any with-Start message: its input is the union of what the message needs and what the Workflow's start needs. + +## When the approval already exists + +Signal-with-Start introduces a case the Service did not have before. `attachApprovalContext` can create the approval, so by the time anyone calls `requestApproval` for that purchase, a Workflow with that Id may already be running. + +Both Operations derive the same Workflow Id from the same item id, as decided in [step 3](/develop/java/nexus/development-walkthrough?step=backing#give-the-approval-workflow-a-stable-id). That is deliberate — it is what lets them agree on which approval they mean — and it is also what creates the collision. + +**By default, `requestApproval` fails in this situation.** Starting a Workflow whose Id is already running is an error, and the Nexus Operation fails with it. + +That default is not arbitrary strictness. A Workflow-backed Operation has only started successfully once its completion callback is attached to a Workflow. If the start quietly did nothing on a conflict, the Operation would report success with no callback attached, and the caller would wait for a decision that could never be delivered. Failing immediately is better than hanging forever. + +The fix is to change the Workflow Id conflict policy on `requestApproval` from its default to **use-existing**. With that set, a start against an already-running approval attaches the Operation's completion callback to that Execution instead of failing. The caller then awaits the approval that is already in flight and receives its decision when it completes. + + + +[core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/handler/ApprovalServiceImpl.java) + +```java + @OperationImpl + public OperationHandler requestApproval() { + return TemporalOperationHandler.create( + (ctx, client, input) -> + client.startWorkflow( + ApprovalWorkflow.class, + ApprovalWorkflow::runApproval, + input, + WorkflowOptions.newBuilder() + .setWorkflowId(ApprovalWorkflowId.forItem(input.getItemId())) + .setWorkflowIdConflictPolicy( + WorkflowIdConflictPolicy.WORKFLOW_ID_CONFLICT_POLICY_USE_EXISTING) + .build())); + } +``` + + + +Two things follow from this, and both are useful. + +**More than one caller can await the same approval.** Every caller whose callback is attached is notified when the Workflow completes, so several systems can each call `requestApproval` for the same purchase and all receive the same decision. The first call creates the approval; the rest attach to it. + +**The Operation becomes idempotent for callers, not just for retries.** [Step 3](/develop/java/nexus/development-walkthrough?step=backing#give-the-approval-workflow-a-stable-id) made the start idempotent against server retries of one request. Use-existing extends that to genuinely separate callers, which is what you want for a purchase that two systems might both submit. + +One limit to know: use-existing attaches to a *running* Execution. If the approval has already completed, there is nothing to attach to, and the call starts a fresh approval rather than returning the old decision. Whoever needs the outcome of a finished approval has to have been attached while it was open, or be told by the handler — which is what the notification in the next step does. + +## Run it in the harder order + +The point of Signal-with-Start is that the note can arrive first, so run it that way. Restart the +handler Worker, then pick an item id that has no approval yet: + + + +```bash title="Run 1 of 3: attach context before the approval exists" +temporal nexus operation execute \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation AttachApprovalContext \ + --operation-id attach-1 \ + --input '{"itemId":"desk-7","requester":"tao","amount":1250,"note":"Approved in the Q3 budget"}' +``` + + + +``` +Results: + Status COMPLETED + Result null +``` + +No approval existed for `desk-7`, so the Operation created one and delivered the note to it: + + + +```bash title="Run 2 of 3: confirm only one approval was created" +temporal workflow list --namespace approval-handler-namespace +``` + + + +``` + Status WorkflowId Type + Running approval-desk-7 ApprovalWorkflow +``` + +Now request the approval for that same item. Under the default conflict policy this would fail, +because a Workflow with that Id is already running. With use-existing it attaches instead: + + + +```bash title="Run 3 of 3: request the approval that already exists" +temporal nexus operation start \ + --namespace approval-caller-namespace \ + --endpoint approval-endpoint \ + --service temporal.samples.approval.v1.ApprovalService \ + --operation RequestApproval \ + --operation-id approval-desk-7 \ + --input '{"itemId":"desk-7","requester":"tao","amount":1250}' +``` + + + +`temporal workflow list` still shows one `approval-desk-7`. The second call did not start a second +approval; it attached its completion callback to the one the note created. Decide it with +`SubmitDecision` as in [step 7](/develop/java/nexus/development-walkthrough?step=messaging#run-the-messaging-operations) +and the result arrives on the Operation started here. + +:::note The two Workflow Id policies + +Two policies govern a start against a Workflow Id already in use, and they cover cases that never overlap: + +- The [Workflow Id conflict policy](/workflow-execution/workflowid-runid#workflow-id-conflict-policy) applies while a Workflow with that Id is **running**. It defaults to failing with `Workflow execution already started` — the default this section replaces with use-existing. +- The [Workflow Id reuse policy](/workflow-execution/workflowid-runid#workflow-id-reuse-policy) applies once the previous Workflow with that Id has **closed**. It defaults to Allow Duplicate, which permits a new Execution. + +Setting use-existing changes only the running case. A start against a completed approval falls to the reuse policy and opens a new one. Set the reuse policy as well if a second approval for the same item is not what you want. + +::: + + +:::tip RESOURCES + +- [Sending messages](/sending-messages) for Signal and Update semantics. +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for sync messaging and async backings. +- [Java Nexus feature guide](/develop/java/nexus/feature-guide) for the caller API. + +::: diff --git a/docs/develop/java/nexus/development-walkthrough/index.mdx b/docs/develop/java/nexus/development-walkthrough/index.mdx new file mode 100644 index 0000000000..db553f4873 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/index.mdx @@ -0,0 +1,64 @@ +--- +id: index +title: Nexus Microservice Development Walkthrough - Java SDK +sidebar_label: Microservice Development Walkthrough +description: Build a Nexus Service end to end in Java, starting from a data contract and adding one Nexus capability at a time to solve a purchase approval problem. +hide_table_of_contents: true +tags: + - Nexus + - Java SDK + - Temporal SDKs +--- + +import NexusMicroserviceWalkthrough, { WalkthroughStep } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; +import Overview from './_steps/overview.mdx'; +import DefineTheDataContract from './_steps/define-the-data-contract.mdx'; +import GenerateCode from './_steps/generate-code.mdx'; +import ChooseBackingImplementation from './_steps/choose-backing-implementation.mdx'; +import ImplementTheService from './_steps/implement-the-service.mdx'; +import PublishInNexus from './_steps/publish-in-nexus.mdx'; +import CallTheService from './_steps/call-the-service.mdx'; +import AddMessaging from './_steps/add-messaging.mdx'; +import SendMessages from './_steps/send-messages.mdx'; +import AddAStandaloneActivity from './_steps/add-a-standalone-activity.mdx'; +import CallTheStandaloneActivity from './_steps/call-the-standalone-activity.mdx'; +import DebuggingAndTips from './_steps/debugging-and-tips.mdx'; + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/develop/java/nexus/temporal-operation-handler.mdx b/docs/develop/java/nexus/temporal-operation-handler.mdx new file mode 100644 index 0000000000..4aa4ce8968 --- /dev/null +++ b/docs/develop/java/nexus/temporal-operation-handler.mdx @@ -0,0 +1,229 @@ +--- +id: temporal-operation-handler +title: Temporal Operation Handler - Java SDK +sidebar_label: Temporal Operation Handler +description: How to implement Nexus Operations with TemporalOperationHandler in the Java SDK. +toc_max_heading_level: 4 +slug: /develop/java/nexus/temporal-operation-handler +tags: + - Nexus + - Java SDK +--- + +:::caution + +The Temporal Operation Handler is pre-release. +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A [Nexus Service](/nexus/services) publishes Operations that other teams call across [Namespace](/namespaces) boundaries. +`TemporalOperationHandler` is how you implement those Operations. + +For the conceptual model, see [Temporal Operation Handler](/nexus/temporal-operation-handler). +This page shows how to write handlers in the Java SDK, migrate from earlier APIs, and compose Workflow, Update, Signal, and Activity backings. + +## What you can do with it + +**Back an Operation with whichever primitive fits the work.** A multi-step process is a Workflow. A single durable step is an [Activity](/activities), with no Workflow wrapped around it. A change to something already running is an Update. The caller sees the same Operation contract either way, and you can change your choice later without touching callers. + +**Combine messaging and a backing in one handler.** A handler can Signal a running Workflow to unblock it and then return a different Execution's result for the caller to await. These are not separate handler types you pick between; they compose inside one start handler. + +**Get observability across the Namespace boundary without wiring it.** The Client handed to your handler propagates [bidirectional links](/nexus/execution-debugging#bi-directional-linking) and request Ids on every call it makes. The caller-side and handler-side Executions are connected in the UI and in [Event History](/encyclopedia/event-history), so a single trace crosses the boundary between two teams' Namespaces. + +**Stay idempotent through retries.** The server retries Nexus start requests, and the request Id travels with them. Deriving the backing Execution's Id from it means a retry targets the same Execution rather than starting a second one. + +**Cancel through the same handler.** The Operation token records which kind of Execution backs the Operation, so a cancellation request reaches the right place. The default behavior is usually what you want, and each kind can be overridden when it is not. + +**Grow a handler without rewriting it.** An Operation that starts out completing inline can later gain an async backing, or send a Signal, without changing handler type or breaking its contract. + +## The Nexus-aware Client + +A `TemporalOperationHandler` start handler receives three things: a context, a Client, and the Operation input. + +The Client is what makes the linking automatic, so prefer it over constructing your own inside a handler. +Reaching for your own Client still works, but messages sent that way are not connected back to the caller. + +The Client exposes two kinds of call, and the distinction shapes how you write the handler. +The examples below use the Java SDK APIs. + +**Async backings — at most one per Operation invocation.** These determine what the Operation *is*, and their result is delivered to the caller through the Nexus completion callback when the underlying Execution finishes. + +- Start a Workflow — the Operation completes when the Workflow returns +- Update a Workflow — the Operation completes when the Update completes +- Start an Activity — the Operation completes when the Activity returns; see [Nexus Standalone Activity](/develop/java/nexus/activity-backed-operations) + +**Sync messaging — as many as you need.** Reach these through the underlying Temporal Client that the Nexus-aware Client exposes. +They take effect during the handler call, still get link propagation, and do not require an async backing. + +- Signal — delivered during the handler call to a Workflow that is already running +- Signal-with-Start — delivers the Signal, starting the Workflow first if it is not already running + +A handler that only sends messages returns a synchronous result, and the Operation completes immediately. + +## Write an Operation handler + +The examples below use a Nexus Service with a `startGreeting` Operation backed by a Workflow, an `updateShippingAddress` Operation backed by an Update, a `cancelOrder` Operation that sends a Signal, and a `greet` Operation backed by an Activity. + +### Back an Operation with a Workflow + +Call `startWorkflow` on the Client and return its result. The Operation completes when the Workflow returns, delivering the Workflow's return value to the caller. + +```java +@OperationImpl +public OperationHandler startGreeting() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startWorkflow( + GreetingWorkflow.class, + GreetingWorkflow::greet, + input, + WorkflowOptions.newBuilder() + .setWorkflowId("greeting-" + input.getName()) + .build())); +} +``` + + +### Back an Operation with an Update + +Back an Operation with an Update when it changes something already running. The target Workflow has to exist already, and the Operation completes when the Update completes. + +```java +@OperationImpl +public OperationHandler updateShippingAddress() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startWorkflowUpdate( + OrderWorkflow.class, + "order-" + input.getOrderId(), + OrderWorkflow::updateShippingAddress, + input, + UpdateOptions.newBuilder(AddressOutput.class) + .setUpdateName("updateShippingAddress") + .setWaitForStage(WorkflowUpdateStage.ACCEPTED) + .build())); +} +``` + + +Three constraints apply to Update-backed Operations, and the first two fail the Operation rather than degrading: + +- **Only the accepted stage is supported.** A Nexus Operation can only back an asynchronous Update, so the wait-for-stage must be "accepted". +- **The Update Id defaults to the Nexus request Id.** Leaving it unset is what you usually want: a retried start request carries the same request Id, so it targets the same Update rather than running a second one. + +The result is async in the normal case, carrying an update-workflow Operation token. If the Update has already completed by the time it is accepted — a retried request with the same Update Id, or an Update that completes immediately — you get a synchronous result instead. + +### Send a Signal from an Operation + +Reach the Workflow Client through the injected Client, send the message, and return a synchronous result. The Operation completes during the handler call, and the Signal is linked back to the caller. + +```java +@OperationImpl +public OperationHandler cancelOrder() { + return TemporalOperationHandler.create( + (context, client, input) -> { + client.getWorkflowClient() + .newUntypedWorkflowStub("order-" + input.getOrderId()) + .signal("requestCancellation", input); + return TemporalOperationResult.sync(null); + }); +} +``` + + +The same Client also offers Signal-with-Start, and a handler may send several messages before returning. + +### Back an Operation with an Activity + +Call `startActivity` when the work is a single durable step. The Activity runs with no parent Workflow, so the options require an Activity Id and at least one timeout. See [Nexus Standalone Activity](/develop/java/nexus/activity-backed-operations). + +```java +@OperationImpl +public OperationHandler greet() { + return TemporalOperationHandler.create( + (context, client, input) -> + client.startActivity( + GreetingActivities.class, + GreetingActivities::greet, + input, + StartActivityOptions.newBuilder() + .setId("greet-" + context.getRequestId()) + .setTaskQueue(HandlerWorker.TASK_QUEUE_NAME) + .setStartToCloseTimeout(Duration.ofSeconds(10)) + .build())); +} +``` + + +## Coming from the earlier handler APIs + +Skip this section if you are new to Nexus. + +Earlier SDK versions had a separate helper per pattern. Existing handlers will keep working, there is no forced migration. + +Operations already in progress are not a concern either. If you need to cancel one, for example, a Workflow-backed Operation started by one of the earlier APIs is cancelled by `TemporalOperationHandler` just as it would have been before. + +| If you used | Use instead | +| --- | --- | +| The Workflow-run helper (`WorkflowRunOperation`, `NewWorkflowRunOperation`, `@workflow_run_operation`) | `TemporalOperationHandler` with a Workflow backing | +| The synchronous handler (`OperationHandler.sync`, `nexus.NewSyncOperation`, `@sync_operation`) | `TemporalOperationHandler` returning a sync result | +| A Workflow wrapping a single Activity | `TemporalOperationHandler` with an Activity backing | +| A Temporal Client fetched inside a handler | The Client injected into the start handler | + +Two things improve when you migrate. Messages and Executions get [bidirectional linking](/nexus/execution-debugging#bi-directional-linking), which hand-fetched Clients do not produce. And one handler type covers every case, so an Operation can change what backs it without changing shape. + +### Migrating a Workflow-backed Operation + +The earlier helper reached the Client through the Operation context and returned a Workflow handle or method reference, rather than being handed a Client and returning an Operation result: + +```java +@OperationImpl +public OperationHandler startGreeting() { + return WorkflowRunOperation.fromWorkflowMethod( + (ctx, details, input) -> + Nexus.getOperationContext() + .getWorkflowClient() + .newWorkflowStub( + GreetingWorkflow.class, + WorkflowOptions.newBuilder() + .setWorkflowId("greeting-" + input.getName()) + .build()) + ::greet); +} +``` + + +Replace it with [Back an Operation with a Workflow](#back-an-operation-with-a-workflow). + +### Migrating a synchronous Operation + +A messaging Operation used to be a synchronous handler that fetched its own Client, which is why those messages produced no links: + +```java +@OperationImpl +public OperationHandler cancelOrder() { + return OperationHandler.sync( + (ctx, details, input) -> { + Nexus.getOperationContext() + .getWorkflowClient() + .newUntypedWorkflowStub("order-" + input.getOrderId()) + .signal("requestCancellation", input); + return null; + }); +} +``` + + +Replace it with [Send a Signal from an Operation](#send-a-signal-from-an-operation). + +:::tip RESOURCES + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the conceptual model. +- [Nexus Services](/nexus/services) and [Nexus Operations](/nexus/operations) for the underlying concepts. +- [Nexus Client Code Generator](/develop/java/nexus/client-code-generator) to generate Service contracts and typed models from one schema. +- [Activity-backed Nexus Operations](/develop/java/nexus/activity-backed-operations) for Activity-backed Operations. +- [Bidirectional linking](/nexus/execution-debugging#bi-directional-linking) for what the Nexus-aware Client gives you. +- [Microservice Development Walkthrough](/develop/java/nexus/development-walkthrough) builds a Nexus Service end to end with the Temporal Operation Handler. +- [Java Nexus feature guide](/develop/java/nexus/feature-guide) +::: diff --git a/docs/develop/python/nexus/activity-backed-operations.mdx b/docs/develop/python/nexus/activity-backed-operations.mdx new file mode 100644 index 0000000000..c07eb0b0e1 --- /dev/null +++ b/docs/develop/python/nexus/activity-backed-operations.mdx @@ -0,0 +1,180 @@ +--- +id: activity-backed-operations +title: Activity-backed Nexus Operations - Python SDK +sidebar_label: Activity-backed Operations +description: How to back a Nexus Operation with a Standalone Activity using TemporalOperationHandler in the Python SDK. +toc_max_heading_level: 4 +slug: /develop/python/nexus/activity-backed-operations +tags: + - Nexus + - Python SDK + - Activities +--- + +:::caution + +Activity-backed Nexus Operations are pre-release and built on the [Temporal Operation Handler](/nexus/temporal-operation-handler). +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A Nexus Operation can be backed by a [Standalone Activity](/standalone-activity) as well as a Workflow. +Starting the Operation starts an Activity Execution that has no parent Workflow, and the Operation completes when that Activity returns. + +For the conceptual model, see [Nexus Standalone Activity](/nexus/standalone-activity). +This page shows how to implement Activity-backed Operations with `TemporalOperationHandler` in the Python SDK. + +## When to use an Activity-backed Operation + +The shape fits work that is one durable step sitting behind a team boundary. +You get all of the following without building any of it yourself: + +- **Durability per call.** Retries on the policy you set, timeouts you control, and a record of every attempt including the last error. +- **Deduplicated starts.** A stable Activity Id means a retried or redelivered request targets the same Activity Execution instead of starting a second one, so you do not need a separate store of processed Ids. +- **Protection from a failing dependency.** Repeated retryable failures trip the [circuit breaker](/nexus/operations#circuit-breaking) rather than piling retries onto a system that is already struggling. +- **A boundary between teams.** Callers get scoped access to named Operations, not write access to your Namespace, and they never see your Task Queues, your Workers, or your implementation. +- **Room to change your mind.** As long as the contract holds you can rewrite what runs behind an Operation, including replacing the Activity with a Workflow later, and no caller changes. + +Either calling style works: a caller Workflow can invoke the Operation as one step of a larger process, or a Client can start it directly as a [Standalone Nexus Operation](/standalone-nexus-operation) with no Workflow on either side. + +A sampling of customer use cases this was built to address follows. + +### Durable webhook and event processing without running a queue + +A service ingests webhooks from third-party providers — payment events, repository pushes, delivery receipts, CRM change notifications. +Each one needs to trigger a single durable task: update a downstream system, kick off a notification, index into search, sync into a warehouse. + +Providers retry aggressively if you do not return `200` within seconds, so the receiver has to accept fast and do the work elsewhere. +The usual way to get there is to assemble it yourself: a queue, a consumer fleet, hand-written retry and dead-letter handling, and a separate store holding processed event Ids for deduplication. +That is a lot of infrastructure whose only job is to run one function reliably. + +An Activity-backed Operation replaces the whole assembly. +The receiver starts the Operation and returns `200` immediately; Temporal owns delivery from that point. +The provider's own event Id becomes the Activity Id, so a redelivered webhook targets the same Activity Execution instead of starting a second one, and the deduplication store disappears. +Retries, backoff, and the record of every attempt come from the Activity. +When a downstream dependency starts failing, the [circuit breaker](/nexus/operations#circuit-breaking) trips rather than letting retries pile up against it. + +The team that owns the receiver is usually not the team that owns the processing, and the Nexus Endpoint is where that boundary lands: scoped access to specific Operations rather than write access to a Namespace. + +### Sandboxing a tool call + +An agent needs to call a tool — an MCP tool, an internal API, something that executes untrusted input. +You want that call to run somewhere with its own credentials, its own network reach, and its own blast radius, not inside the process orchestrating the agent. + +An Activity-backed Operation puts a Namespace boundary between the two. +The tool runs on Workers in the Namespace that owns it, under that Namespace's credentials, and the caller only ever sees the Operation contract. +The call is still durable and retried, and it is still traceable end to end, but the caller cannot reach past the contract into the environment the tool runs in. +See [Build AI applications with Temporal](/with-ai) for how this fits alongside the rest of the agent stack. + +### A durable front door to another system + +Any system you call can fail, time out, or be down when you need it — a third-party API, a legacy internal service, an unreliable piece of infrastructure. +Wrapping that call in an Activity-backed Operation makes every call against it durable: retried on your policy, bounded by your timeouts, and recorded whether it succeeded or not. + +Written once and published as a Nexus Service, it becomes a connector that every team calls instead of each writing its own integration. +The team that owns it can fix a bug or change the implementation behind the contract, without a coordinated rollout across every consumer. + +### Related patterns + +The same shape fits anything that is an external trigger, one durable step, and a team boundary. + +- **Asynchronous user actions from a backend-for-frontend.** A user clicks "export my data" or "revoke my sessions"; the BFF starts the Operation and hands the client a handle to poll. +- **Consumer offload.** A Kafka or event-stream consumer starts an Operation, commits its offset, and lets Temporal own durability from there. The event Id is the deduplication key. +- **Platform actions triggered by CI/CD.** A pipeline step requests a compliance scan, a canary step, or a credential rotation. The platform team owns the handler; consumer teams get scoped Endpoint access. +- **Scheduled platform tasks.** A scheduler fires an Operation and a shared platform team's Workers run the task. + +## How it works + +Use `TemporalOperationHandler` and call `startActivity` on the injected Nexus-aware Client. +The handler returns an async result carrying an activity-execution Operation token, and the server delivers the Activity's result to the caller through the Nexus completion callback when the Activity finishes. + +```python +from . import activities + + +@service_handler(service=GreetingNexusService) +class GreetingNexusServiceHandler: + @nexus.temporal_operation + async def greet( + self, + ctx: nexus.TemporalStartOperationContext, + client: nexus.TemporalNexusClient, + input: GreetingInput, + ) -> nexus.TemporalOperationResult[GreetingOutput]: + return await client.start_activity( + activities.greet, + input, + id=f"greet-{ctx.request_id}", + start_to_close_timeout=timedelta(seconds=10), + ) +``` + + +You write the Activity the same way whichever side calls it. +The same Activity Function can be executed by a Workflow and started behind a Nexus Operation with no code changes — nothing in its definition is Nexus-specific. +What differs is how it is started, not what it is. + +```python +# activities.py +@activity.defn +async def greet(input: GreetingInput) -> GreetingOutput: + return GreetingOutput(message=f"Hello, {input.name}") +``` + + +### Required options + +Starting an Activity this way needs values that a Workflow-called Activity does not. + +- **An Activity Id**, unique within the Namespace. There is no parent Workflow to scope it. +- **A timeout.** At least one of start-to-close or schedule-to-close. + +The Task Queue is optional. It defaults to the Task Queue the Operation is running on, which is what the samples above rely on. Set it explicitly to run the Activity on its own Worker fleet rather than the one the Endpoint targets. + +Deriving the Id from the Nexus request Id makes the start idempotent. +The server retries a Nexus start request using the same request Id, so each retry targets the same Activity Id rather than starting a second Activity. + +Setting the Activity Id conflict policy to use-existing attaches to an already-running Activity with that Id instead of failing. +Combined with an Id derived from the Operation *input* rather than the request Id, this lets several Nexus Operations share one Activity Execution and all receive its result. + +### Register the Worker + +Register the Activity implementations and the Nexus Service implementation on a Worker polling the Endpoint's target Task Queue. +There is no Workflow implementation to register. + +```python +worker = Worker( + client, + task_queue=TASK_QUEUE_NAME, + activities=[activities.greet], + nexus_service_handlers=[GreetingNexusServiceHandler()], +) +``` + + +## Cancellation + +Worth remembering, because it is the one behavioral difference from a Workflow-backed Operation that surprises people. + +A Workflow is interrupted by a cancellation request. An Activity is not: the Worker only learns about it on the next heartbeat, so an Activity that never heartbeats runs until it completes or hits its timeout, no matter how many cancellation requests the caller sends. + +Nothing about this is Nexus-specific. See [Activity cancellation](/activity-execution#cancellation) for how to heartbeat, what to do with the resulting cancellation exception, and why a heartbeat timeout matters. For a short Activity that finishes well inside its timeout and doesn't have the risk of hanging, however, a heartbeat is not needed. + +## Choose between an Activity and a Workflow + +Back an Operation with an **Activity** when the work is a single step: one external call, one computation, one notification. +You get retries, timeouts, and a durable record of the step, without an Event History tracking orchestration you are not doing. + +Back an Operation with a **Workflow** when the work has more than one step, needs to wait for something, needs to receive [messages](/sending-messages), or needs durable intermediate state. +For example, an approval that blocks for a human decision is a Workflow, not an Activity. + +Sample code: `{code not yet live}` + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for the conceptual model. +- [Temporal Operation Handler](/develop/python/nexus/temporal-operation-handler) for the handler type and the Nexus-aware Client. +- [Standalone Activity](/standalone-activity) for the underlying concept. +- [Standalone Nexus Operation](/standalone-nexus-operation) for starting Operations without a caller Workflow. + +::: diff --git a/docs/develop/python/nexus/client-code-generator.mdx b/docs/develop/python/nexus/client-code-generator.mdx new file mode 100644 index 0000000000..af900a024a --- /dev/null +++ b/docs/develop/python/nexus/client-code-generator.mdx @@ -0,0 +1,361 @@ +--- +id: client-code-generator +title: Nexus Client Code Generator - Python SDK +sidebar_label: Client Code Generator +description: How to install nexgen and generate typed Nexus models and Service definitions for Python. +toc_max_heading_level: 4 +slug: /develop/python/nexus/client-code-generator +tags: + - Nexus + - Python SDK +--- + +A [Nexus Service](/nexus/services) is a contract meant to be shared across team boundaries. +Those teams often work in different languages, so the same request and response types get hand-written for each SDK. +Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler. + +The **[Nexus Client Code Generator](https://github.com/temporalio/nexgen)** removes those copies. +You describe your types and [Nexus Operations](/nexus/operations) once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. +The generator is a command-line tool named `nexgen`, distributed from the [temporalio/nexgen](https://github.com/temporalio/nexgen) repository. + +For a short overview of what the generator produces and why, see [Nexus Client Code Generator](/nexus/client-code-generator). +This page covers installation, schema authoring, and generating and using Python output. + +:::caution + +`nexgen` is pre-release software and may not retain backwards compatibility with previous versions of the tool. +It is not yet published to any package registry, so you build it from source as described in [Install the generator](#install-the-generator). + +::: + +## What the generator produces + +The generator produces a client library in Go, Java, Python, or TypeScript for the inputs and outputs of your Nexus Operations. The generated types check values against the contract as they are sent and received, so a violation surfaces as an error rather than as bad data. + +That client library contains three things: + +- **A typed model.** An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema. +- **A runtime validator.** One validator per type, applied when a value is parsed off the wire and again when it is serialized onto it. See [Validation guarantees](#validation-guarantees) for more. +- **A [Nexus Service Contract](/glossary#nexus-service-contract) definition**, for a file that declares Services. These are the Service and Operation declarations you register on a Worker and call from a caller Workflow. A pure JSON Schema file declares none, so it produces only the models and their validators. + +Additionally, constraint failures are aggregated into a single native error listing every violation, each naming the offending field and the bound it broke. +A handler maps that error to a `BAD_REQUEST` [Nexus error](/nexus/error-handling), so a malformed request tells the caller everything that was wrong with it in one response. + +The supported schema subset is deliberately strict. +Anything ambiguous, or anything that cannot be expressed identically in all generated languages, is rejected when you run the generator with a diagnostic explaining how to express it correctly. +The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another. + +## Supported languages + +This page covers the Python output from `nexgen`. + +## Definition files + +Types are modeled with [JSON Schema 2020-12](https://json-schema.org/draft/2020-12). +A definition file is one of two kinds, decided by what sits at its root. +A file is one or the other, never both. + +**Pure JSON Schema.** The root of the document is itself a type, and reusable types live under `$defs`. +Use this when you only need data models shared across languages, with no Service or Operation declarations. + +**Nexus document.** Add a root `nexusrpc: "1.0.0"` marker to enable a `services` section. +The root becomes an envelope: Services and their Operations sit at the top level, and your types live under `$defs`. +Only this kind can declare a Service. + +The two kinds compose across files, so a contract is not limited to one of them. +A Service contract is often a Nexus document declaring the Services and Operations, plus pure JSON Schema files holding the types it `$ref`s by relative path. +The [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) closure described below is built that way. + +The examples on this page use [`samples/schemas/chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml) from the repository, abbreviated here: + +```yaml +nexusrpc: '1.0.0' +$schema: https://json-schema.org/draft/2020-12/schema +services: + ChatService: + fqn: example.chat.v1.ChatService + description: Send messages and look up rooms. + operations: + sendMessage: + description: Post a message to a room. + input: { $ref: '#/$defs/SendMessageInput' } + output: { $ref: '#/$defs/SendMessageOutput' } + getRoom: + description: Look up a room by id. + input: + type: object + additionalProperties: false + properties: + roomId: { type: string } + required: [roomId] + output: { $ref: '#/$defs/Room' } + ping: + description: Liveness probe. +$defs: + SendMessageInput: + type: object + additionalProperties: false + properties: + roomId: { type: string } + message: { $ref: '#/$defs/Message' } + required: [roomId, message] + SendMessageOutput: + type: object + additionalProperties: false + properties: + messageId: { type: string } + required: [messageId] +``` + +See [Definition files](https://github.com/temporalio/nexgen#definition-files) in the generator's README for details on that file. + +### How names are derived + +You write two kinds of name in the schema: one for the Service, and one for each Operation. In the sample above they are `ChatService` and `sendMessage`: + +```yaml +services: + ChatService: # the Service name + operations: + sendMessage: # the Operation name +``` + +Those are the only names you write. From each one the generator produces two more: a wire name and a name in your code. You declare neither of them. + +Give each name the casing that matches what it becomes: + +- A **Service** name is PascalCase: `ChatService`. A Service becomes a type in the generated code, and types are PascalCase. +- An **Operation** name is camelCase: `sendMessage`. An Operation becomes a method on that type, and the generator cases it like any other member. + +The first letter is the part the generator enforces: a Service has to start uppercase and an Operation lowercase. Both must begin with a letter and contain only letters and digits. + +``` +service name `chatService` must match `^[A-Z][a-zA-Z\d]+$` (start uppercase, then +letters/digits); set the wire name via `fqn` if it must differ +``` + +:::note Overriding the wire name + +The `fqn` in that error — a fully qualified name — is optional, and you can skip it to start. + +It lets a Service or Operation carry a wire name of your choosing rather than the one the generator derives from the name you wrote. Because it never becomes a code identifier, it accepts characters a name cannot: that is how a Service gets a wire name of `example.chat.v1.ChatService`, or an Operation one of `poll-messages`. Wire names are covered just below. + +Use `fqn` when you need to match a contract that is already published, or when you want a versioned, namespaced wire name. Otherwise leave it out. + +::: + +**The wire name** is the string the caller and the handler exchange, and what appears in Event History and the Temporal UI. Neither side types it — both take it from the generated code. + +Unless you override it, the wire name is the name from the definition file converted to PascalCase. In the sample that gives the Service a wire name of **`ChatService`** and the `sendMessage` Operation a wire name of **`SendMessage`**. + +In the sample above the Service does override it using `fqn`, so the Service wire name becomes **`example.chat.v1.ChatService`**. + +**The name in your code** is what you call. The generator recases the name you wrote to match the conventions of the language it is generating for. With no override in play, the two derived names line up like this: + +| You write | Wire name | Java | Go | Python | TypeScript | +| --- | --- | --- | --- | --- | --- | +| `ChatService` | `ChatService` | `ChatService` | `ChatService` | `ChatService` | `chatService` | +| `sendMessage` | `SendMessage` | `sendMessage` | `SendMessage` | `send_message` | `sendMessage` | + +Operations become camelCase methods in Java and TypeScript, snake_case in Python, and PascalCase in Go. + +An Operation's `input` and `output` are each optional. +The `ping` Operation above declares neither, which generates an Operation that takes and returns nothing. +When present, each must be an object type, so that a field can be added later without breaking the wire format. + +The repository holds four example definitions under [`samples/schemas/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas): +[`chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml), +the feature-diverse [`showcase.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/showcase.nexusrpc.yaml), +the pure-schema [`temporal.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/temporal.yaml), +and a multi-file closure under [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) showing how types split across files resolve through `$ref`. +The `kb/` closure starts at [`kb.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/kb/kb.nexusrpc.yaml) and pulls in types from its [`content/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/content) and [`tree/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/tree) subdirectories. + +## Install the generator + +Build the `nexgen` binary from source with Cargo, the Rust build tool: + +```bash +git clone https://github.com/temporalio/nexgen.git +cd nexgen +cargo build --release +``` + +The binary lands at `target/release/nexgen`. +Confirm it works and check which targets your build supports: + +```bash +./target/release/nexgen --version +./target/release/nexgen --help +``` + +## Generate code + +Every language uses the same shape: `nexgen ... --output

`. +Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags. + +:::note + +The output directory name becomes the generated package or module name. +Name it after your domain, such as `chat`, not after the language. +Pointing `--output` at a directory named `go` produces `package go`, which is not valid Go. + +::: + +### Python + +```bash +nexgen python samples/schemas/chat.nexusrpc.yaml --output ./chat +``` + +This writes an importable package: `models.py`, `services.py`, and an `__init__.py` that re-exports both. Place it where the code that imports it can reach it — the output directory *is* the package. + +That also means the directory name becomes an importable module name, so pick one that does not collide with the standard library. `./chat` is safe, but a Service whose subject happens to share a name with a stdlib module is not: `./email`, `./queue`, and `./calendar` each shadow one for anything on that path. Qualify those — `./calendar_v1`. + +:::note Python output needs Pydantic + +Generated models are [Pydantic](https://docs.pydantic.dev/) models. Installing the Python SDK on its own does not bring Pydantic with it, so importing the generated package fails with `ModuleNotFoundError: No module named 'pydantic'` until you add it. The SDK ships an extra for this: + +```bash +pip install 'temporalio[pydantic]' +``` + +Your Worker and Client then need the Pydantic Data Converter — see [Use Pydantic models](/develop/python/data-handling/data-conversion#use-pydantic-models) for the setup. + +That converter is not optional if your schema uses any temporal `format`. `datetime.date`, `datetime.time`, and `datetime.datetime` can only be converted by it, and the generator maps `date`, `time`, and `date-time` onto those types. + +::: + +## Dates, times, and durations + +TypeScript's `--date-time-types` is the only place you choose how a date or time is represented. Elsewhere the generator decides: Java uses `java.time`, Python `datetime` and `timedelta`, and Go `time.Time` and `time.Duration`. Two cases hand you the wire string to work with instead of a date type — `format: time` in Java, and every date and time format under TypeScript's default `string` mode. + +Whichever type you get, every language writes the same bytes. Dates and times use [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339), which is a profile of [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601). ISO 8601 permits many optional spellings of the same instant, and RFC 3339 narrows them to one so two systems cannot read a timestamp differently. RFC 3339 specifies timestamps rather than durations, so durations follow ISO 8601. + +## How validation works + +Validation is the one behavior the generated types add, so a call can fail with a contract violation that hand-written types would not have caught. Only the wiring of that validation differs between languages. + +| SDK | How validation reaches the wire | Extra step | +| ---------- | ----------------------------------------------------------- | ---------- | +| Go | Generated `MarshalJSON` and `UnmarshalJSON` on each model | None | +| Java | Generated Jackson serializer and deserializer on each model | None | +| Python | Pydantic model validation | [Use the Pydantic data converter](/develop/python/data-handling/data-conversion#use-pydantic-models) | +| TypeScript | Generated mapper classes | Call the mapper yourself | + +In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. +TypeScript requires an explicit call, covered in [Validate payloads in TypeScript](/develop/typescript/nexus/client-code-generator#validate-payloads-in-typescript). + +### Validation guarantees + +The two directions do not check the same things. + +**Parsing a value off the wire** enforces required fields and every value constraint, aggregating all violations into one error. This is the direction that protects a handler from a malformed request. + +**Serializing a value onto the wire** enforces value constraints — lengths, bounds, counts, patterns. It does not report a required field you left unset. The field is omitted from the payload and the peer rejects it, so the failure surfaces as a `BAD_REQUEST` from the other side rather than as a local error at the point you built the object. + +For code that catches and logs a violation, see the per-language examples in [Use the generated code](#use-the-generated-code). + +## Use the generated code + +**Whether the code is generated or written by hand, you use it the same way.** It is a Service definition and a set of types: register it on a Worker, and call it from a caller Workflow exactly as described in your SDK's Nexus guide. The one difference is that generated types validate themselves, so a call can fail with a contract violation for you to catch. + +Each example below registers a handler, calls the Operation from a caller Workflow, and catches a validation failure. + +### Python + +The generator emits `ChatService` as a `@service`-decorated class whose attributes are typed `Operation` declarations. +Bind a handler to it: + +```python +@service_handler(service=ChatService) +class ChatServiceHandler: + @sync_operation + async def send_message( + self, ctx: StartOperationContext, input: SendMessageInput + ) -> SendMessageOutput: + return SendMessageOutput(messageId=store(input)) +``` + +Pass the handler to your Worker as `nexus_service_handlers=[ChatServiceHandler()]`, then call it from a caller Workflow: + +```python +client = workflow.create_nexus_client(service=ChatService, endpoint="chat-endpoint") + +output = await client.execute_operation( + ChatService.send_message, + SendMessageInput(roomId="r1", message=Message(kind="text", body="hi")), +) +``` + +Generated Python fields are snake_case with the wire name as an alias. +Construct models with either name, and read them with the snake_case attribute: `SendMessageInput(roomId="r1", ...)` constructs, and `output.message_id` reads. + +There is nothing to catch at the call. Pydantic validates when the model is constructed, so an invalid `SendMessageInput` raises `pydantic.ValidationError` at the constructor and never reaches the Nexus client. Handle it where you build the model. + +## Regenerate after a contract change + +Generated files carry a `DO NOT EDIT` header and are replaced wholesale on the next run. There is no merge step, so anything you add to them is lost. + +Two habits make this safe: + +- **Commit generated code and regenerate as its own commit.** The diff then shows exactly what the contract change did to each language. +- **Fix names in the schema, not the output.** When a generated identifier is wrong for your language, set a per-language override in the contract so the fix survives regeneration. See [Naming and overrides](https://github.com/temporalio/nexgen#naming--overrides) for the available keys. + +If you generate into the wrong directory, delete what landed there and run the generator again with corrected flags. Do not edit the `package` or module declaration to match where the files ended up as the next run will overwrite it. + +## Schema defaults + +A property can declare a `default`, which makes it optional for a caller to supply: + +```yaml +sampleValue: + type: integer + default: 0 +``` + +A caller that leaves `sampleValue` unset sends a payload without the field, and the receiver reads `0`. The default is never written into the payload, so an omitted field stays omitted rather than being filled in before it is sent. + +:::caution Changing a default is a breaking change + +The default lives in the generated code, not in the payload. Temporal replays a Workflow by re-reading the payloads already recorded in its [Event History](/encyclopedia/event-history) using whatever code the Worker is running now, so changing a default changes what those recorded payloads mean. + +If the value affects which commands the Workflow produces, replaying an in-flight Workflow fails with a [non-determinism error](/troubleshooting/execution-failures#non-determinism-error) — the [deterministic constraints](/workflow-definition#deterministic-constraints) that govern any change to Workflow code apply here too. If it does not affect commands, nothing fails and the behavior changes silently, which is harder to catch. + +Treat a default as part of the contract. Adding a replacement field is not sufficient on its own: old payloads omit the new field too, so replay reads that field's default and can still diverge. Any change to how existing payloads are interpreted needs a [Workflow versioning](/workflow-definition#workflow-versioning) plan that keeps in-flight Executions on their original behavior. + +::: + +Because the field is optional, Go and Java give you two members side by side, so nothing depends on remembering that a default exists: + +- The field itself, which is empty when the caller omitted it — `getSampleValue()` returns `null` in Java, and `SampleValue` is a `nil *int64` in Go. +- An accessor named after it that substitutes the default — `getSampleValueOrDefault()` and `SampleValueOrDefault()`. + +Use the first when you need to know whether the caller supplied a value, and the second when you just want a number. + +TypeScript has no accessor. `sampleValue` is `undefined` when unset, and the generator exports a `DEFAULT_SAMPLE_VALUE` constant you apply yourself: `sampleValue ?? DEFAULT_SAMPLE_VALUE`. + +Python has neither. Pydantic applies defaults when the model is constructed, so `sample_value` always holds a value and an omitted field reads the same as one explicitly set to `0`. + +## Supported schema features + +The generator implements a curated subset of [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) chosen so that every accepted construct lowers identically into all generated languages. + +Fully supported: `properties`, `required`, `default`, `minProperties` and `maxProperties`, `dependentRequired`, string and numeric bounds, `items`, `minItems` and `maxItems`, `minContains` and `maxContains`, `allOf`, the recognized nullable pattern `oneOf: [{type: T}, {type: "null"}]`, and the `title`, `description`, and `deprecated` annotations. + +Partially supported: `type` (single-string form only), `additionalProperties`, `propertyNames`, `const` and `enum` (scalars only), `format`, `pattern` (a portable RE2-safe subset), `multipleOf`, `contentEncoding`, `uniqueItems`, `contains`, `oneOf` (branches must be separable by a decidable selector), and `$ref` with `$defs` (local files only). + +Deliberately rejected, because they have no coherent typed lowering across all generated languages: `anyOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `contentMediaType`, and `contentSchema`. + +For the current per-keyword support table, see the [nexgen README](https://github.com/temporalio/nexgen#supported-json-schema-features). + +:::tip RESOURCES + +- [temporalio/nexgen](https://github.com/temporalio/nexgen) for the generator, its README, and the example schemas. +- [Nexus Services](/nexus/services) for the Service contract concept. +- Nexus feature guides for registering Services and calling Operations: + [Go](/develop/go/nexus/feature-guide) | + [Java](/develop/java/nexus/feature-guide) | + [Python](/develop/python/nexus/feature-guide) | + [TypeScript](/develop/typescript/nexus/feature-guide) + +::: diff --git a/docs/develop/python/nexus/temporal-operation-handler.mdx b/docs/develop/python/nexus/temporal-operation-handler.mdx new file mode 100644 index 0000000000..7ebfa04c0e --- /dev/null +++ b/docs/develop/python/nexus/temporal-operation-handler.mdx @@ -0,0 +1,215 @@ +--- +id: temporal-operation-handler +title: Temporal Operation Handler - Python SDK +sidebar_label: Temporal Operation Handler +description: How to implement Nexus Operations with TemporalOperationHandler in the Python SDK. +toc_max_heading_level: 4 +slug: /develop/python/nexus/temporal-operation-handler +tags: + - Nexus + - Python SDK +--- + +:::caution + +The Temporal Operation Handler is pre-release. +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A [Nexus Service](/nexus/services) publishes Operations that other teams call across [Namespace](/namespaces) boundaries. +`TemporalOperationHandler` is how you implement those Operations. + +For the conceptual model, see [Temporal Operation Handler](/nexus/temporal-operation-handler). +This page shows how to write handlers in the Python SDK, migrate from earlier APIs, and compose Workflow, Update, Signal, and Activity backings. + +## What you can do with it + +**Back an Operation with whichever primitive fits the work.** A multi-step process is a Workflow. A single durable step is an [Activity](/activities), with no Workflow wrapped around it. A change to something already running is an Update. The caller sees the same Operation contract either way, and you can change your choice later without touching callers. + +**Combine messaging and a backing in one handler.** A handler can Signal a running Workflow to unblock it and then return a different Execution's result for the caller to await. These are not separate handler types you pick between; they compose inside one start handler. + +**Get observability across the Namespace boundary without wiring it.** The Client handed to your handler propagates [bidirectional links](/nexus/execution-debugging#bi-directional-linking) and request Ids on every call it makes. The caller-side and handler-side Executions are connected in the UI and in [Event History](/encyclopedia/event-history), so a single trace crosses the boundary between two teams' Namespaces. + +**Stay idempotent through retries.** The server retries Nexus start requests, and the request Id travels with them. Deriving the backing Execution's Id from it means a retry targets the same Execution rather than starting a second one. + +**Cancel through the same handler.** The Operation token records which kind of Execution backs the Operation, so a cancellation request reaches the right place. The default behavior is usually what you want, and each kind can be overridden when it is not. + +**Grow a handler without rewriting it.** An Operation that starts out completing inline can later gain an async backing, or send a Signal, without changing handler type or breaking its contract. + +## The Nexus-aware Client + +A `TemporalOperationHandler` start handler receives three things: a context, a Client, and the Operation input. + +The Client is what makes the linking automatic, so prefer it over constructing your own inside a handler. +Reaching for your own Client still works, but messages sent that way are not connected back to the caller. + +The Client exposes two kinds of call, and the distinction shapes how you write the handler. +The examples below use the Python SDK APIs. + +**Async backings — at most one per Operation invocation.** These determine what the Operation *is*, and their result is delivered to the caller through the Nexus completion callback when the underlying Execution finishes. + +- Start a Workflow — the Operation completes when the Workflow returns +- Update a Workflow — the Operation completes when the Update completes +- Start an Activity — the Operation completes when the Activity returns; see [Nexus Standalone Activity](/develop/python/nexus/activity-backed-operations) + +**Sync messaging — as many as you need.** Reach these through the underlying Temporal Client that the Nexus-aware Client exposes. +They take effect during the handler call, still get link propagation, and do not require an async backing. + +- Signal — delivered during the handler call to a Workflow that is already running +- Signal-with-Start — delivers the Signal, starting the Workflow first if it is not already running + +A handler that only sends messages returns a synchronous result, and the Operation completes immediately. + +## Write an Operation handler + +The examples below use a Nexus Service with a `startGreeting` Operation backed by a Workflow, an `updateShippingAddress` Operation backed by an Update, a `cancelOrder` Operation that sends a Signal, and a `greet` Operation backed by an Activity. + +### Back an Operation with a Workflow + +Call `startWorkflow` on the Client and return its result. The Operation completes when the Workflow returns, delivering the Workflow's return value to the caller. + +```python +@nexus.temporal_operation +async def start_greeting( + self, + _ctx: nexus.TemporalStartOperationContext, + client: nexus.TemporalNexusClient, + input: GreetingInput, +) -> nexus.TemporalOperationResult[GreetingOutput]: + return await client.start_workflow( + GreetingWorkflow.run, input, id=f"greeting-{input.name}" + ) +``` + + +### Back an Operation with an Update + +Back an Operation with an Update when it changes something already running. The target Workflow has to exist already, and the Operation completes when the Update completes. + +```python +@nexus.temporal_operation +async def update_shipping_address( + self, + _ctx: nexus.TemporalStartOperationContext, + client: nexus.TemporalNexusClient, + input: UpdateAddressInput, +) -> nexus.TemporalOperationResult[AddressOutput]: + return await client.start_workflow_update( + f"order-{input.order_id}", + OrderWorkflow.update_shipping_address, + input, + ) +``` + + +Three constraints apply to Update-backed Operations, and the first two fail the Operation rather than degrading: + +- **Only the accepted stage is supported.** A Nexus Operation can only back an asynchronous Update, so the wait-for-stage must be "accepted". +- **The Update Id defaults to the Nexus request Id.** Leaving it unset is what you usually want: a retried start request carries the same request Id, so it targets the same Update rather than running a second one. + +The result is async in the normal case, carrying an update-workflow Operation token. If the Update has already completed by the time it is accepted — a retried request with the same Update Id, or an Update that completes immediately — you get a synchronous result instead. + +### Send a Signal from an Operation + +Reach the Workflow Client through the injected Client, send the message, and return a synchronous result. The Operation completes during the handler call, and the Signal is linked back to the caller. + +```python +@nexus.temporal_operation +async def cancel_order( + self, + _ctx: nexus.TemporalStartOperationContext, + client: nexus.TemporalNexusClient, + input: CancelOrderInput, +) -> nexus.TemporalOperationResult[None]: + await client.client.get_workflow_handle( + f"order-{input.order_id}" + ).signal("requestCancellation", input) + return nexus.TemporalOperationResult.sync(None) +``` + + +The same Client also offers Signal-with-Start, and a handler may send several messages before returning. + +### Back an Operation with an Activity + +Call `startActivity` when the work is a single durable step. The Activity runs with no parent Workflow, so the options require an Activity Id and at least one timeout. See [Nexus Standalone Activity](/develop/python/nexus/activity-backed-operations). + +```python +@nexus.temporal_operation +async def greet( + self, + ctx: nexus.TemporalStartOperationContext, + client: nexus.TemporalNexusClient, + input: GreetingInput, +) -> nexus.TemporalOperationResult[GreetingOutput]: + return await client.start_activity( + activities.greet, + input, + id=f"greet-{ctx.request_id}", + task_queue=TASK_QUEUE_NAME, + start_to_close_timeout=timedelta(seconds=10), + ) +``` + + +## Coming from the earlier handler APIs + +Skip this section if you are new to Nexus. + +Earlier SDK versions had a separate helper per pattern. Existing handlers will keep working, there is no forced migration. + +Operations already in progress are not a concern either. If you need to cancel one, for example, a Workflow-backed Operation started by one of the earlier APIs is cancelled by `TemporalOperationHandler` just as it would have been before. + +| If you used | Use instead | +| --- | --- | +| The Workflow-run helper (`WorkflowRunOperation`, `NewWorkflowRunOperation`, `@workflow_run_operation`) | `TemporalOperationHandler` with a Workflow backing | +| The synchronous handler (`OperationHandler.sync`, `nexus.NewSyncOperation`, `@sync_operation`) | `TemporalOperationHandler` returning a sync result | +| A Workflow wrapping a single Activity | `TemporalOperationHandler` with an Activity backing | +| A Temporal Client fetched inside a handler | The Client injected into the start handler | + +Two things improve when you migrate. Messages and Executions get [bidirectional linking](/nexus/execution-debugging#bi-directional-linking), which hand-fetched Clients do not produce. And one handler type covers every case, so an Operation can change what backs it without changing shape. + +### Migrating a Workflow-backed Operation + +The earlier helper reached the Client through the Operation context and returned a Workflow handle or method reference, rather than being handed a Client and returning an Operation result: + +```python +@nexus.workflow_run_operation +async def start_greeting( + self, ctx: nexus.WorkflowRunOperationContext, input: GreetingInput +) -> nexus.WorkflowHandle[GreetingOutput]: + return await ctx.start_workflow( + GreetingWorkflow.run, input, id=f"greeting-{input.name}" + ) +``` + + +Replace it with [Back an Operation with a Workflow](#back-an-operation-with-a-workflow). + +### Migrating a synchronous Operation + +A messaging Operation used to be a synchronous handler that fetched its own Client, which is why those messages produced no links: + +```python +@nexusrpc.handler.sync_operation +async def cancel_order( + self, ctx: nexusrpc.handler.StartOperationContext, input: CancelOrderInput +) -> None: + await nexus.client().get_workflow_handle( + f"order-{input.order_id}" + ).signal("requestCancellation", input) +``` + + +Replace it with [Send a Signal from an Operation](#send-a-signal-from-an-operation). + +:::tip RESOURCES + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the conceptual model. +- [Nexus Services](/nexus/services) and [Nexus Operations](/nexus/operations) for the underlying concepts. +- [Nexus Client Code Generator](/develop/python/nexus/client-code-generator) to generate Service contracts and typed models from one schema. +- [Activity-backed Nexus Operations](/develop/python/nexus/activity-backed-operations) for Activity-backed Operations. +- [Bidirectional linking](/nexus/execution-debugging#bi-directional-linking) for what the Nexus-aware Client gives you. +- [Python Nexus feature guide](/develop/python/nexus/feature-guide) +::: diff --git a/docs/develop/typescript/nexus/activity-backed-operations.mdx b/docs/develop/typescript/nexus/activity-backed-operations.mdx new file mode 100644 index 0000000000..10ae778612 --- /dev/null +++ b/docs/develop/typescript/nexus/activity-backed-operations.mdx @@ -0,0 +1,171 @@ +--- +id: activity-backed-operations +title: Activity-backed Nexus Operations - TypeScript SDK +sidebar_label: Activity-backed Operations +description: How to back a Nexus Operation with a Standalone Activity using TemporalOperationHandler in the TypeScript SDK. +toc_max_heading_level: 4 +slug: /develop/typescript/nexus/activity-backed-operations +tags: + - Nexus + - TypeScript SDK + - Activities +--- + +:::caution + +Activity-backed Nexus Operations are pre-release and built on the [Temporal Operation Handler](/nexus/temporal-operation-handler). +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A Nexus Operation can be backed by a [Standalone Activity](/standalone-activity) as well as a Workflow. +Starting the Operation starts an Activity Execution that has no parent Workflow, and the Operation completes when that Activity returns. + +For the conceptual model, see [Nexus Standalone Activity](/nexus/standalone-activity). +This page shows how to implement Activity-backed Operations with `TemporalOperationHandler` in the TypeScript SDK. + +## When to use an Activity-backed Operation + +The shape fits work that is one durable step sitting behind a team boundary. +You get all of the following without building any of it yourself: + +- **Durability per call.** Retries on the policy you set, timeouts you control, and a record of every attempt including the last error. +- **Deduplicated starts.** A stable Activity Id means a retried or redelivered request targets the same Activity Execution instead of starting a second one, so you do not need a separate store of processed Ids. +- **Protection from a failing dependency.** Repeated retryable failures trip the [circuit breaker](/nexus/operations#circuit-breaking) rather than piling retries onto a system that is already struggling. +- **A boundary between teams.** Callers get scoped access to named Operations, not write access to your Namespace, and they never see your Task Queues, your Workers, or your implementation. +- **Room to change your mind.** As long as the contract holds you can rewrite what runs behind an Operation, including replacing the Activity with a Workflow later, and no caller changes. + +Either calling style works: a caller Workflow can invoke the Operation as one step of a larger process, or a Client can start it directly as a [Standalone Nexus Operation](/standalone-nexus-operation) with no Workflow on either side. + +A sampling of customer use cases this was built to address follows. + +### Durable webhook and event processing without running a queue + +A service ingests webhooks from third-party providers — payment events, repository pushes, delivery receipts, CRM change notifications. +Each one needs to trigger a single durable task: update a downstream system, kick off a notification, index into search, sync into a warehouse. + +Providers retry aggressively if you do not return `200` within seconds, so the receiver has to accept fast and do the work elsewhere. +The usual way to get there is to assemble it yourself: a queue, a consumer fleet, hand-written retry and dead-letter handling, and a separate store holding processed event Ids for deduplication. +That is a lot of infrastructure whose only job is to run one function reliably. + +An Activity-backed Operation replaces the whole assembly. +The receiver starts the Operation and returns `200` immediately; Temporal owns delivery from that point. +The provider's own event Id becomes the Activity Id, so a redelivered webhook targets the same Activity Execution instead of starting a second one, and the deduplication store disappears. +Retries, backoff, and the record of every attempt come from the Activity. +When a downstream dependency starts failing, the [circuit breaker](/nexus/operations#circuit-breaking) trips rather than letting retries pile up against it. + +The team that owns the receiver is usually not the team that owns the processing, and the Nexus Endpoint is where that boundary lands: scoped access to specific Operations rather than write access to a Namespace. + +### Sandboxing a tool call + +An agent needs to call a tool — an MCP tool, an internal API, something that executes untrusted input. +You want that call to run somewhere with its own credentials, its own network reach, and its own blast radius, not inside the process orchestrating the agent. + +An Activity-backed Operation puts a Namespace boundary between the two. +The tool runs on Workers in the Namespace that owns it, under that Namespace's credentials, and the caller only ever sees the Operation contract. +The call is still durable and retried, and it is still traceable end to end, but the caller cannot reach past the contract into the environment the tool runs in. +See [Build AI applications with Temporal](/with-ai) for how this fits alongside the rest of the agent stack. + +### A durable front door to another system + +Any system you call can fail, time out, or be down when you need it — a third-party API, a legacy internal service, an unreliable piece of infrastructure. +Wrapping that call in an Activity-backed Operation makes every call against it durable: retried on your policy, bounded by your timeouts, and recorded whether it succeeded or not. + +Written once and published as a Nexus Service, it becomes a connector that every team calls instead of each writing its own integration. +The team that owns it can fix a bug or change the implementation behind the contract, without a coordinated rollout across every consumer. + +### Related patterns + +The same shape fits anything that is an external trigger, one durable step, and a team boundary. + +- **Asynchronous user actions from a backend-for-frontend.** A user clicks "export my data" or "revoke my sessions"; the BFF starts the Operation and hands the client a handle to poll. +- **Consumer offload.** A Kafka or event-stream consumer starts an Operation, commits its offset, and lets Temporal own durability from there. The event Id is the deduplication key. +- **Platform actions triggered by CI/CD.** A pipeline step requests a compliance scan, a canary step, or a credential rotation. The platform team owns the handler; consumer teams get scoped Endpoint access. +- **Scheduled platform tasks.** A scheduler fires an Operation and a shared platform team's Workers run the task. + +## How it works + +Use `TemporalOperationHandler` and call `startActivity` on the injected Nexus-aware Client. +The handler returns an async result carrying an activity-execution Operation token, and the server delivers the Activity's result to the caller through the Nexus completion callback when the Activity finishes. + +```typescript +export const greetingServiceHandler = nexus.serviceHandler(greetingService, { + greet: new temporalnexus.TemporalOperationHandler({ + async start(ctx, client, input) { + return await client.typedActivity().startActivity('greet', { + id: `greet-${ctx.requestId}`, + args: [input], + startToCloseTimeout: '10s', + }); + }, + }), +}); +``` + + +You write the Activity the same way whichever side calls it. +The same Activity Function can be executed by a Workflow and started behind a Nexus Operation with no code changes — nothing in its definition is Nexus-specific. +What differs is how it is started, not what it is. + +```typescript +export async function greet(input: GreetingInput): Promise { + return { message: `Hello, ${input.name}` }; +} +``` + + +### Required options + +Starting an Activity this way needs values that a Workflow-called Activity does not. + +- **An Activity Id**, unique within the Namespace. There is no parent Workflow to scope it. +- **A timeout.** At least one of start-to-close or schedule-to-close. + +The Task Queue is optional. It defaults to the Task Queue the Operation is running on, which is what the samples above rely on. Set it explicitly to run the Activity on its own Worker fleet rather than the one the Endpoint targets. + +Deriving the Id from the Nexus request Id makes the start idempotent. +The server retries a Nexus start request using the same request Id, so each retry targets the same Activity Id rather than starting a second Activity. + +Setting the Activity Id conflict policy to use-existing attaches to an already-running Activity with that Id instead of failing. +Combined with an Id derived from the Operation *input* rather than the request Id, this lets several Nexus Operations share one Activity Execution and all receive its result. + +### Register the Worker + +Register the Activity implementations and the Nexus Service implementation on a Worker polling the Endpoint's target Task Queue. +There is no Workflow implementation to register. + +```typescript +const worker = await Worker.create({ + taskQueue: TASK_QUEUE_NAME, + activities, + nexusServices: [greetingServiceHandler], +}); +``` + + +## Cancellation + +Worth remembering, because it is the one behavioral difference from a Workflow-backed Operation that surprises people. + +A Workflow is interrupted by a cancellation request. An Activity is not: the Worker only learns about it on the next heartbeat, so an Activity that never heartbeats runs until it completes or hits its timeout, no matter how many cancellation requests the caller sends. + +Nothing about this is Nexus-specific. See [Activity cancellation](/activity-execution#cancellation) for how to heartbeat, what to do with the resulting cancellation exception, and why a heartbeat timeout matters. For a short Activity that finishes well inside its timeout and doesn't have the risk of hanging, however, a heartbeat is not needed. + +## Choose between an Activity and a Workflow + +Back an Operation with an **Activity** when the work is a single step: one external call, one computation, one notification. +You get retries, timeouts, and a durable record of the step, without an Event History tracking orchestration you are not doing. + +Back an Operation with a **Workflow** when the work has more than one step, needs to wait for something, needs to receive [messages](/sending-messages), or needs durable intermediate state. +For example, an approval that blocks for a human decision is a Workflow, not an Activity. + +Sample code: `{code not yet live}` + +:::tip RESOURCES + +- [Nexus Standalone Activity](/nexus/standalone-activity) for the conceptual model. +- [Temporal Operation Handler](/develop/typescript/nexus/temporal-operation-handler) for the handler type and the Nexus-aware Client. +- [Standalone Activity](/standalone-activity) for the underlying concept. +- [Standalone Nexus Operation](/standalone-nexus-operation) for starting Operations without a caller Workflow. + +::: diff --git a/docs/develop/typescript/nexus/client-code-generator.mdx b/docs/develop/typescript/nexus/client-code-generator.mdx new file mode 100644 index 0000000000..9190cdadd6 --- /dev/null +++ b/docs/develop/typescript/nexus/client-code-generator.mdx @@ -0,0 +1,389 @@ +--- +id: client-code-generator +title: Nexus Client Code Generator - TypeScript SDK +sidebar_label: Client Code Generator +description: How to install nexgen and generate typed Nexus models and Service definitions for TypeScript. +toc_max_heading_level: 4 +slug: /develop/typescript/nexus/client-code-generator +tags: + - Nexus + - TypeScript SDK +--- + +A [Nexus Service](/nexus/services) is a contract meant to be shared across team boundaries. +Those teams often work in different languages, so the same request and response types get hand-written for each SDK. +Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler. + +The **[Nexus Client Code Generator](https://github.com/temporalio/nexgen)** removes those copies. +You describe your types and [Nexus Operations](/nexus/operations) once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. +The generator is a command-line tool named `nexgen`, distributed from the [temporalio/nexgen](https://github.com/temporalio/nexgen) repository. + +For a short overview of what the generator produces and why, see [Nexus Client Code Generator](/nexus/client-code-generator). +This page covers installation, schema authoring, and generating and using TypeScript output. + +:::caution + +`nexgen` is pre-release software and may not retain backwards compatibility with previous versions of the tool. +It is not yet published to any package registry, so you build it from source as described in [Install the generator](#install-the-generator). + +::: + +## What the generator produces + +The generator produces a client library in Go, Java, Python, or TypeScript for the inputs and outputs of your Nexus Operations. The generated types check values against the contract as they are sent and received, so a violation surfaces as an error rather than as bad data. + +That client library contains three things: + +- **A typed model.** An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema. +- **A runtime validator.** One validator per type, applied when a value is parsed off the wire and again when it is serialized onto it. See [Validation guarantees](#validation-guarantees) for more. +- **A [Nexus Service Contract](/glossary#nexus-service-contract) definition**, for a file that declares Services. These are the Service and Operation declarations you register on a Worker and call from a caller Workflow. A pure JSON Schema file declares none, so it produces only the models and their validators. + +Additionally, constraint failures are aggregated into a single native error listing every violation, each naming the offending field and the bound it broke. +A handler maps that error to a `BAD_REQUEST` [Nexus error](/nexus/error-handling), so a malformed request tells the caller everything that was wrong with it in one response. + +The supported schema subset is deliberately strict. +Anything ambiguous, or anything that cannot be expressed identically in all generated languages, is rejected when you run the generator with a diagnostic explaining how to express it correctly. +The generator prefers to fail loudly at generation time over emitting code that behaves differently in one language than another. + +## Supported languages + +This page covers the TypeScript output from `nexgen`. + +## Definition files + +Types are modeled with [JSON Schema 2020-12](https://json-schema.org/draft/2020-12). +A definition file is one of two kinds, decided by what sits at its root. +A file is one or the other, never both. + +**Pure JSON Schema.** The root of the document is itself a type, and reusable types live under `$defs`. +Use this when you only need data models shared across languages, with no Service or Operation declarations. + +**Nexus document.** Add a root `nexusrpc: "1.0.0"` marker to enable a `services` section. +The root becomes an envelope: Services and their Operations sit at the top level, and your types live under `$defs`. +Only this kind can declare a Service. + +The two kinds compose across files, so a contract is not limited to one of them. +A Service contract is often a Nexus document declaring the Services and Operations, plus pure JSON Schema files holding the types it `$ref`s by relative path. +The [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) closure described below is built that way. + +The examples on this page use [`samples/schemas/chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml) from the repository, abbreviated here: + +```yaml +nexusrpc: '1.0.0' +$schema: https://json-schema.org/draft/2020-12/schema +services: + ChatService: + fqn: example.chat.v1.ChatService + description: Send messages and look up rooms. + operations: + sendMessage: + description: Post a message to a room. + input: { $ref: '#/$defs/SendMessageInput' } + output: { $ref: '#/$defs/SendMessageOutput' } + getRoom: + description: Look up a room by id. + input: + type: object + additionalProperties: false + properties: + roomId: { type: string } + required: [roomId] + output: { $ref: '#/$defs/Room' } + ping: + description: Liveness probe. +$defs: + SendMessageInput: + type: object + additionalProperties: false + properties: + roomId: { type: string } + message: { $ref: '#/$defs/Message' } + required: [roomId, message] + SendMessageOutput: + type: object + additionalProperties: false + properties: + messageId: { type: string } + required: [messageId] +``` + +See [Definition files](https://github.com/temporalio/nexgen#definition-files) in the generator's README for details on that file. + +### How names are derived + +You write two kinds of name in the schema: one for the Service, and one for each Operation. In the sample above they are `ChatService` and `sendMessage`: + +```yaml +services: + ChatService: # the Service name + operations: + sendMessage: # the Operation name +``` + +Those are the only names you write. From each one the generator produces two more: a wire name and a name in your code. You declare neither of them. + +Give each name the casing that matches what it becomes: + +- A **Service** name is PascalCase: `ChatService`. A Service becomes a type in the generated code, and types are PascalCase. +- An **Operation** name is camelCase: `sendMessage`. An Operation becomes a method on that type, and the generator cases it like any other member. + +The first letter is the part the generator enforces: a Service has to start uppercase and an Operation lowercase. Both must begin with a letter and contain only letters and digits. + +``` +service name `chatService` must match `^[A-Z][a-zA-Z\d]+$` (start uppercase, then +letters/digits); set the wire name via `fqn` if it must differ +``` + +:::note Overriding the wire name + +The `fqn` in that error — a fully qualified name — is optional, and you can skip it to start. + +It lets a Service or Operation carry a wire name of your choosing rather than the one the generator derives from the name you wrote. Because it never becomes a code identifier, it accepts characters a name cannot: that is how a Service gets a wire name of `example.chat.v1.ChatService`, or an Operation one of `poll-messages`. Wire names are covered just below. + +Use `fqn` when you need to match a contract that is already published, or when you want a versioned, namespaced wire name. Otherwise leave it out. + +::: + +**The wire name** is the string the caller and the handler exchange, and what appears in Event History and the Temporal UI. Neither side types it — both take it from the generated code. + +Unless you override it, the wire name is the name from the definition file converted to PascalCase. In the sample that gives the Service a wire name of **`ChatService`** and the `sendMessage` Operation a wire name of **`SendMessage`**. + +In the sample above the Service does override it using `fqn`, so the Service wire name becomes **`example.chat.v1.ChatService`**. + +**The name in your code** is what you call. The generator recases the name you wrote to match the conventions of the language it is generating for. With no override in play, the two derived names line up like this: + +| You write | Wire name | Java | Go | Python | TypeScript | +| --- | --- | --- | --- | --- | --- | +| `ChatService` | `ChatService` | `ChatService` | `ChatService` | `ChatService` | `chatService` | +| `sendMessage` | `SendMessage` | `sendMessage` | `SendMessage` | `send_message` | `sendMessage` | + +Operations become camelCase methods in Java and TypeScript, snake_case in Python, and PascalCase in Go. + +An Operation's `input` and `output` are each optional. +The `ping` Operation above declares neither, which generates an Operation that takes and returns nothing. +When present, each must be an object type, so that a field can be added later without breaking the wire format. + +The repository holds four example definitions under [`samples/schemas/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas): +[`chat.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/chat.nexusrpc.yaml), +the feature-diverse [`showcase.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/showcase.nexusrpc.yaml), +the pure-schema [`temporal.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/temporal.yaml), +and a multi-file closure under [`kb/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb) showing how types split across files resolve through `$ref`. +The `kb/` closure starts at [`kb.nexusrpc.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/kb/kb.nexusrpc.yaml) and pulls in types from its [`content/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/content) and [`tree/`](https://github.com/temporalio/nexgen/tree/main/samples/schemas/kb/tree) subdirectories. + +## Install the generator + +Build the `nexgen` binary from source with Cargo, the Rust build tool: + +```bash +git clone https://github.com/temporalio/nexgen.git +cd nexgen +cargo build --release +``` + +The binary lands at `target/release/nexgen`. +Confirm it works and check which targets your build supports: + +```bash +./target/release/nexgen --version +./target/release/nexgen --help +``` + +## Generate code + +Every language uses the same shape: `nexgen ... --output `. +Inputs are positional and may be files or directories, so you can pass a whole multi-file schema closure. Some languages have extra flags. + +:::note + +The output directory name becomes the generated package or module name. +Name it after your domain, such as `chat`, not after the language. +Pointing `--output` at a directory named `go` produces `package go`, which is not valid Go. + +::: + +### TypeScript + +```bash +nexgen ts samples/schemas/chat.nexusrpc.yaml --output ./chat +``` + +TypeScript accepts `--date-time-types` to choose how date and time fields are represented in memory. There are three choices: + +- `string`, the default, keeps every date and time field as the [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339) string that appears on the wire. It adds no runtime dependency and round-trips losslessly, but you parse and compare the strings yourself. +- `date` maps `date-time` fields to a JavaScript `Date`. This is lossy: a `Date` is a UTC instant, so the original offset is folded away and precision is capped at milliseconds. +- `temporal` maps to the [TC39 Temporal API](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal), a JavaScript standard for dates and times that is unrelated to Temporal the platform. It preserves the offset and sub-second precision, and requires the `Temporal` global. + +The chat schema has no date or time fields, so this command uses [`temporal.yaml`](https://github.com/temporalio/nexgen/blob/main/samples/schemas/temporal.yaml), which has one field for each of `date`, `date-time`, `time`, and `duration`: + +```bash +nexgen ts samples/schemas/temporal.yaml --output ./events --date-time-types temporal +``` + +## Dates, times, and durations + +TypeScript's `--date-time-types` is the only place you choose how a date or time is represented. Elsewhere the generator decides: Java uses `java.time`, Python `datetime` and `timedelta`, and Go `time.Time` and `time.Duration`. Two cases hand you the wire string to work with instead of a date type — `format: time` in Java, and every date and time format under TypeScript's default `string` mode. + +Whichever type you get, every language writes the same bytes. Dates and times use [RFC 3339](https://www.rfc-editor.org/rfc/rfc3339), which is a profile of [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601). ISO 8601 permits many optional spellings of the same instant, and RFC 3339 narrows them to one so two systems cannot read a timestamp differently. RFC 3339 specifies timestamps rather than durations, so durations follow ISO 8601. + +## How validation works + +Validation is the one behavior the generated types add, so a call can fail with a contract violation that hand-written types would not have caught. Only the wiring of that validation differs between languages. + +| SDK | How validation reaches the wire | Extra step | +| ---------- | ----------------------------------------------------------- | ---------- | +| Go | Generated `MarshalJSON` and `UnmarshalJSON` on each model | None | +| Java | Generated Jackson serializer and deserializer on each model | None | +| Python | Pydantic model validation | [Use the Pydantic data converter](/develop/python/data-handling/data-conversion#use-pydantic-models) | +| TypeScript | Generated mapper classes | Call the mapper yourself | + +In Go, Java, and Python the validator sits in the serialization hook the Temporal data converter already calls, so validation happens on its own once the models are in use. +TypeScript requires an explicit call, covered in [Validate payloads in TypeScript](#validate-payloads-in-typescript). + +### Validation guarantees + +The two directions do not check the same things. + +**Parsing a value off the wire** enforces required fields and every value constraint, aggregating all violations into one error. This is the direction that protects a handler from a malformed request. + +**Serializing a value onto the wire** enforces value constraints — lengths, bounds, counts, patterns. It does not report a required field you left unset. The field is omitted from the payload and the peer rejects it, so the failure surfaces as a `BAD_REQUEST` from the other side rather than as a local error at the point you built the object. + +For code that catches and logs a violation, see the per-language examples in [Use the generated code](#use-the-generated-code). + +## Use the generated code + +**Whether the code is generated or written by hand, you use it the same way.** It is a Service definition and a set of types: register it on a Worker, and call it from a caller Workflow exactly as described in your SDK's Nexus guide. The one difference is that generated types validate themselves, so a call can fail with a contract violation for you to catch. + +Each example below registers a handler, calls the Operation from a caller Workflow, and catches a validation failure. + +### TypeScript + +The generator emits a `chatService` Service definition plus, for each type, an interface and a companion `Mapper` class: + +```typescript +export const chatService = nexus.service('example.chat.v1.ChatService', { + sendMessage: nexus.operation({ name: 'SendMessage' }), + getRoom: nexus.operation({ name: 'GetRoom' }), + ping: nexus.operation({ name: 'Ping' }), +}); +``` + +Register a handler against that definition with `nexus.serviceHandler(chatService, { ... })`, and create a caller with `workflow.createNexusServiceClient({ service: chatService, endpoint: 'chat-endpoint' })`. + +#### Validate payloads in TypeScript + +:::caution + +In TypeScript the generated validator only runs when you call the mapper. +No generated payload converter exists, so nothing calls it for you. + +::: + +Each generated type comes with a mapper exposing two methods. +`fromIntermediate` validates an untrusted plain value and returns the typed model. +`toIntermediate` validates a model and returns its plain wire form. +Call them at both edges of every Operation, on the handler side and the caller side: + +```typescript +const handler = nexus.serviceHandler(chatService, { + async sendMessage(_ctx, input) { + const request = new SendMessageInputMapper().fromIntermediate(input); + const output = { messageId: await store(request) }; + return new SendMessageOutputMapper().toIntermediate(output) as SendMessageOutput; + }, +}); +``` + +The caller side is the mirror image. Map the request out before executing the Operation, and map the result back in when it returns: + +```typescript +const client = workflow.createNexusServiceClient({ + service: chatService, + endpoint: 'chat-endpoint', +}); + +const wire = new SendMessageInputMapper().toIntermediate(input) as SendMessageInput; +const raw = await client.executeOperation(chatService.operations.sendMessage, wire); +const output = new SendMessageOutputMapper().fromIntermediate(raw); +``` + +The cast is expected in both examples: `toIntermediate` returns `unknown`, because its result is a plain wire value rather than the model type the Operation declares. + +Skipping the mapper is the failure to watch for, because nothing reports it. +The value handed to your handler is typed as the model, since `nexus.operation` declares it that way, but at runtime it is only whatever was deserialized. +A handler that ignores the mapper compiles, type-checks, and returns correct results for valid payloads, while enforcing none of the constraints in your schema. + +When a payload does violate the contract, `fromIntermediate` throws a `ValidationError` carrying every violation at once: + +``` +ValidationError: 2 validation error(s): roomId: required; message.body: expected string +``` + +The error also exposes a `violations` array of `{ path, reason }` objects, so a handler can convert it into a `BAD_REQUEST` Nexus error with the full list intact. + +## Regenerate after a contract change + +Generated files carry a `DO NOT EDIT` header and are replaced wholesale on the next run. There is no merge step, so anything you add to them is lost. + +Two habits make this safe: + +- **Commit generated code and regenerate as its own commit.** The diff then shows exactly what the contract change did to each language. +- **Fix names in the schema, not the output.** When a generated identifier is wrong for your language, set a per-language override in the contract so the fix survives regeneration. See [Naming and overrides](https://github.com/temporalio/nexgen#naming--overrides) for the available keys. + +If you generate into the wrong directory, delete what landed there and run the generator again with corrected flags. Do not edit the `package` or module declaration to match where the files ended up as the next run will overwrite it. + +## Schema defaults + +A property can declare a `default`, which makes it optional for a caller to supply: + +```yaml +sampleValue: + type: integer + default: 0 +``` + +A caller that leaves `sampleValue` unset sends a payload without the field, and the receiver reads `0`. The default is never written into the payload, so an omitted field stays omitted rather than being filled in before it is sent. + +:::caution Changing a default is a breaking change + +The default lives in the generated code, not in the payload. Temporal replays a Workflow by re-reading the payloads already recorded in its [Event History](/encyclopedia/event-history) using whatever code the Worker is running now, so changing a default changes what those recorded payloads mean. + +If the value affects which commands the Workflow produces, replaying an in-flight Workflow fails with a [non-determinism error](/troubleshooting/execution-failures#non-determinism-error) — the [deterministic constraints](/workflow-definition#deterministic-constraints) that govern any change to Workflow code apply here too. If it does not affect commands, nothing fails and the behavior changes silently, which is harder to catch. + +Treat a default as part of the contract. Adding a replacement field is not sufficient on its own: old payloads omit the new field too, so replay reads that field's default and can still diverge. Any change to how existing payloads are interpreted needs a [Workflow versioning](/workflow-definition#workflow-versioning) plan that keeps in-flight Executions on their original behavior. + +::: + +Because the field is optional, Go and Java give you two members side by side, so nothing depends on remembering that a default exists: + +- The field itself, which is empty when the caller omitted it — `getSampleValue()` returns `null` in Java, and `SampleValue` is a `nil *int64` in Go. +- An accessor named after it that substitutes the default — `getSampleValueOrDefault()` and `SampleValueOrDefault()`. + +Use the first when you need to know whether the caller supplied a value, and the second when you just want a number. + +TypeScript has no accessor. `sampleValue` is `undefined` when unset, and the generator exports a `DEFAULT_SAMPLE_VALUE` constant you apply yourself: `sampleValue ?? DEFAULT_SAMPLE_VALUE`. + +Python has neither. Pydantic applies defaults when the model is constructed, so `sample_value` always holds a value and an omitted field reads the same as one explicitly set to `0`. + +## Supported schema features + +The generator implements a curated subset of [JSON Schema 2020-12](https://json-schema.org/draft/2020-12) chosen so that every accepted construct lowers identically into all generated languages. + +Fully supported: `properties`, `required`, `default`, `minProperties` and `maxProperties`, `dependentRequired`, string and numeric bounds, `items`, `minItems` and `maxItems`, `minContains` and `maxContains`, `allOf`, the recognized nullable pattern `oneOf: [{type: T}, {type: "null"}]`, and the `title`, `description`, and `deprecated` annotations. + +Partially supported: `type` (single-string form only), `additionalProperties`, `propertyNames`, `const` and `enum` (scalars only), `format`, `pattern` (a portable RE2-safe subset), `multipleOf`, `contentEncoding`, `uniqueItems`, `contains`, `oneOf` (branches must be separable by a decidable selector), and `$ref` with `$defs` (local files only). + +Deliberately rejected, because they have no coherent typed lowering across all generated languages: `anyOf`, `not`, `if`/`then`/`else`, `dependentSchemas`, `prefixItems`, `unevaluatedProperties`, `unevaluatedItems`, `contentMediaType`, and `contentSchema`. + +For the current per-keyword support table, see the [nexgen README](https://github.com/temporalio/nexgen#supported-json-schema-features). + +:::tip RESOURCES + +- [temporalio/nexgen](https://github.com/temporalio/nexgen) for the generator, its README, and the example schemas. +- [Nexus Services](/nexus/services) for the Service contract concept. +- Nexus feature guides for registering Services and calling Operations: + [Go](/develop/go/nexus/feature-guide) | + [Java](/develop/java/nexus/feature-guide) | + [Python](/develop/python/nexus/feature-guide) | + [TypeScript](/develop/typescript/nexus/feature-guide) + +::: diff --git a/docs/develop/typescript/nexus/temporal-operation-handler.mdx b/docs/develop/typescript/nexus/temporal-operation-handler.mdx new file mode 100644 index 0000000000..89d8b7640a --- /dev/null +++ b/docs/develop/typescript/nexus/temporal-operation-handler.mdx @@ -0,0 +1,203 @@ +--- +id: temporal-operation-handler +title: Temporal Operation Handler - TypeScript SDK +sidebar_label: Temporal Operation Handler +description: How to implement Nexus Operations with TemporalOperationHandler in the TypeScript SDK. +toc_max_heading_level: 4 +slug: /develop/typescript/nexus/temporal-operation-handler +tags: + - Nexus + - TypeScript SDK +--- + +:::caution + +The Temporal Operation Handler is pre-release. +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A [Nexus Service](/nexus/services) publishes Operations that other teams call across [Namespace](/namespaces) boundaries. +`TemporalOperationHandler` is how you implement those Operations. + +For the conceptual model, see [Temporal Operation Handler](/nexus/temporal-operation-handler). +This page shows how to write handlers in the TypeScript SDK, migrate from earlier APIs, and compose Workflow, Update, Signal, and Activity backings. + +## What you can do with it + +**Back an Operation with whichever primitive fits the work.** A multi-step process is a Workflow. A single durable step is an [Activity](/activities), with no Workflow wrapped around it. A change to something already running is an Update. The caller sees the same Operation contract either way, and you can change your choice later without touching callers. + +**Combine messaging and a backing in one handler.** A handler can Signal a running Workflow to unblock it and then return a different Execution's result for the caller to await. These are not separate handler types you pick between; they compose inside one start handler. + +**Get observability across the Namespace boundary without wiring it.** The Client handed to your handler propagates [bidirectional links](/nexus/execution-debugging#bi-directional-linking) and request Ids on every call it makes. The caller-side and handler-side Executions are connected in the UI and in [Event History](/encyclopedia/event-history), so a single trace crosses the boundary between two teams' Namespaces. + +**Stay idempotent through retries.** The server retries Nexus start requests, and the request Id travels with them. Deriving the backing Execution's Id from it means a retry targets the same Execution rather than starting a second one. + +**Cancel through the same handler.** The Operation token records which kind of Execution backs the Operation, so a cancellation request reaches the right place. The default behavior is usually what you want, and each kind can be overridden when it is not. + +**Grow a handler without rewriting it.** An Operation that starts out completing inline can later gain an async backing, or send a Signal, without changing handler type or breaking its contract. + +## The Nexus-aware Client + +A `TemporalOperationHandler` start handler receives three things: a context, a Client, and the Operation input. + +The Client is what makes the linking automatic, so prefer it over constructing your own inside a handler. +Reaching for your own Client still works, but messages sent that way are not connected back to the caller. + +The Client exposes two kinds of call, and the distinction shapes how you write the handler. +The examples below use the TypeScript SDK APIs. + +**Async backings — at most one per Operation invocation.** These determine what the Operation *is*, and their result is delivered to the caller through the Nexus completion callback when the underlying Execution finishes. + +- Start a Workflow — the Operation completes when the Workflow returns +- Update a Workflow — the Operation completes when the Update completes +- Start an Activity — the Operation completes when the Activity returns; see [Nexus Standalone Activity](/develop/typescript/nexus/activity-backed-operations) + +**Sync messaging — as many as you need.** Reach these through the underlying Temporal Client that the Nexus-aware Client exposes. +They take effect during the handler call, still get link propagation, and do not require an async backing. + +- Signal — delivered during the handler call to a Workflow that is already running +- Signal-with-Start — delivers the Signal, starting the Workflow first if it is not already running + +A handler that only sends messages returns a synchronous result, and the Operation completes immediately. + +## Write an Operation handler + +The examples below use a Nexus Service with a `startGreeting` Operation backed by a Workflow, an `updateShippingAddress` Operation backed by an Update, a `cancelOrder` Operation that sends a Signal, and a `greet` Operation backed by an Activity. + +### Back an Operation with a Workflow + +Call `startWorkflow` on the Client and return its result. The Operation completes when the Workflow returns, delivering the Workflow's return value to the caller. + +```typescript +const startGreeting = new temporalnexus.TemporalOperationHandler({ + async start(ctx, client, input) { + return await client.startWorkflow(greetingWorkflow, { + args: [input], + workflowId: `greeting-${input.name}`, + }); + }, +}); +``` + + +### Back an Operation with an Update + +Back an Operation with an Update when it changes something already running. The target Workflow has to exist already, and the Operation completes when the Update completes. + +```typescript +const updateShippingAddressOp = new temporalnexus.TemporalOperationHandler< + UpdateAddressInput, + AddressOutput +>({ + async start(ctx, client, input) { + return await client + .getWorkflowHandle(`order-${input.orderId}`) + .update(shippingAddressUpdate, { args: [input] }); + }, +}); +``` + + +Three constraints apply to Update-backed Operations, and the first two fail the Operation rather than degrading: + +- **Only the accepted stage is supported.** A Nexus Operation can only back an asynchronous Update, so the wait-for-stage must be "accepted". +- **The Update Id defaults to the Nexus request Id.** Leaving it unset is what you usually want: a retried start request carries the same request Id, so it targets the same Update rather than running a second one. + +The result is async in the normal case, carrying an update-workflow Operation token. If the Update has already completed by the time it is accepted — a retried request with the same Update Id, or an Update that completes immediately — you get a synchronous result instead. + +### Send a Signal from an Operation + +Reach the Workflow Client through the injected Client, send the message, and return a synchronous result. The Operation completes during the handler call, and the Signal is linked back to the caller. + +```typescript +const cancelOrder = new temporalnexus.TemporalOperationHandler({ + async start(ctx, client, input) { + await client.getWorkflowHandle(`order-${input.orderId}`).signal(requestCancellation, input); + return temporalnexus.TemporalOperationResult.sync(undefined); + }, +}); +``` + + +The same Client also offers Signal-with-Start, and a handler may send several messages before returning. + +### Back an Operation with an Activity + +Call `startActivity` when the work is a single durable step. The Activity runs with no parent Workflow, so the options require an Activity Id and at least one timeout. See [Nexus Standalone Activity](/develop/typescript/nexus/activity-backed-operations). + +```typescript +const greet = new temporalnexus.TemporalOperationHandler({ + async start(ctx, client, input) { + return await client.typedActivity().startActivity('greet', { + id: `greet-${ctx.requestId}`, + args: [input], + taskQueue: TASK_QUEUE_NAME, + startToCloseTimeout: '10s', + }); + }, +}); +``` + + +## Coming from the earlier handler APIs + +Skip this section if you are new to Nexus. + +Earlier SDK versions had a separate helper per pattern. Existing handlers will keep working, there is no forced migration. + +Operations already in progress are not a concern either. If you need to cancel one, for example, a Workflow-backed Operation started by one of the earlier APIs is cancelled by `TemporalOperationHandler` just as it would have been before. + +| If you used | Use instead | +| --- | --- | +| The Workflow-run helper (`WorkflowRunOperation`, `NewWorkflowRunOperation`, `@workflow_run_operation`) | `TemporalOperationHandler` with a Workflow backing | +| The synchronous handler (`OperationHandler.sync`, `nexus.NewSyncOperation`, `@sync_operation`) | `TemporalOperationHandler` returning a sync result | +| A Workflow wrapping a single Activity | `TemporalOperationHandler` with an Activity backing | +| A Temporal Client fetched inside a handler | The Client injected into the start handler | + +Two things improve when you migrate. Messages and Executions get [bidirectional linking](/nexus/execution-debugging#bi-directional-linking), which hand-fetched Clients do not produce. And one handler type covers every case, so an Operation can change what backs it without changing shape. + +### Migrating a Workflow-backed Operation + +The earlier helper reached the Client through the Operation context and returned a Workflow handle or method reference, rather than being handed a Client and returning an Operation result: + +```typescript +const startGreeting = new temporalnexus.WorkflowRunOperationHandler( + async (ctx, input: GreetingInput) => + await temporalnexus.startWorkflow(ctx, greetingWorkflow, { + args: [input], + workflowId: `greeting-${input.name}`, + }), +); +``` + + +Replace it with [Back an Operation with a Workflow](#back-an-operation-with-a-workflow). + +### Migrating a synchronous Operation + +A messaging Operation used to be a synchronous handler that fetched its own Client, which is why those messages produced no links: + +```typescript +nexus.serviceHandler(orderService, { + async cancelOrder(ctx, input) { + await temporalnexus + .getClient() + .workflow.getHandle(`order-${input.orderId}`) + .signal(requestCancellation, input); + }, +}); +``` + + +Replace it with [Send a Signal from an Operation](#send-a-signal-from-an-operation). + +:::tip RESOURCES + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) for the conceptual model. +- [Nexus Services](/nexus/services) and [Nexus Operations](/nexus/operations) for the underlying concepts. +- [Nexus Client Code Generator](/develop/typescript/nexus/client-code-generator) to generate Service contracts and typed models from one schema. +- [Activity-backed Nexus Operations](/develop/typescript/nexus/activity-backed-operations) for Activity-backed Operations. +- [Bidirectional linking](/nexus/execution-debugging#bi-directional-linking) for what the Nexus-aware Client gives you. +- [TypeScript Nexus feature guide](/develop/typescript/nexus/feature-guide) +::: diff --git a/docs/encyclopedia/nexus/nexus-client-code-generator.mdx b/docs/encyclopedia/nexus/nexus-client-code-generator.mdx new file mode 100644 index 0000000000..2e6e7dd16b --- /dev/null +++ b/docs/encyclopedia/nexus/nexus-client-code-generator.mdx @@ -0,0 +1,49 @@ +--- +id: nexus-client-code-generator +title: Nexus Client Code Generator +sidebar_label: Nexus Client Code Generator +description: The Nexus Client Code Generator turns one schema into typed models, runtime validators, and Nexus Service definitions for Go, Java, Python, and TypeScript. +toc_max_heading_level: 4 +slug: /nexus/client-code-generator +tags: + - Nexus + - Concepts +--- + +:::caution + +`nexgen` is pre-release software and may not retain backwards compatibility with previous versions of the tool. +It is not yet published to any package registry, so you build it from source. + +::: + +A [Nexus Service](/nexus/services) is a contract meant to be shared across team boundaries. +Those teams often work in different languages, so the same request and response types get hand-written for each SDK. +Hand-written copies drift: a field is required on one side and optional on the other, a bound is enforced by the caller but not the handler. + +The **[Nexus Client Code Generator](https://github.com/temporalio/nexgen)** removes those copies. +You describe your types and [Nexus Operations](/nexus/operations) once in a definition file, and the generator emits the equivalent library code for Go, Java, Python, and TypeScript. +The generator is a command-line tool named `nexgen`, distributed from the [temporalio/nexgen](https://github.com/temporalio/nexgen) repository. + +## What the generator produces + +The generator produces a client library for the inputs and outputs of your Nexus Operations. The generated types check values against the contract as they are sent and received, so a violation surfaces as an error rather than as bad data. + +That client library contains three things: + +- **A typed model.** An idiomatic struct, class, interface, or dataclass, with doc comments carried over from the schema. +- **A runtime validator.** One validator per type, applied when a value is parsed off the wire and again when it is serialized onto it. +- **A [Nexus Service Contract](/glossary#nexus-service-contract) definition**, for a file that declares Services. These are the Service and Operation declarations you register on a Worker and call from a caller Workflow. A pure JSON Schema file declares none, so it produces only the models and their validators. + +Constraint failures are aggregated into a single native error listing every violation, each naming the offending field and the bound it broke. +A handler maps that error to a `BAD_REQUEST` [Nexus error](/nexus/error-handling), so a malformed request tells the caller everything that was wrong with it in one response. + +## Related + +- How to use the Nexus Client Code Generator: + [Go](/develop/go/nexus/client-code-generator) · + [Java](/develop/java/nexus/client-code-generator) · + [Python](/develop/python/nexus/client-code-generator) · + [TypeScript](/develop/typescript/nexus/client-code-generator) +- [Temporal Operation Handler](/nexus/temporal-operation-handler) — implement generated Service Operations +- [Microservice Development Walkthrough](/develop/java/nexus/development-walkthrough) — end-to-end Java guide diff --git a/docs/encyclopedia/nexus/nexus-standalone-activity.mdx b/docs/encyclopedia/nexus/nexus-standalone-activity.mdx new file mode 100644 index 0000000000..88d8ee4094 --- /dev/null +++ b/docs/encyclopedia/nexus/nexus-standalone-activity.mdx @@ -0,0 +1,67 @@ +--- +id: nexus-standalone-activity +title: Nexus Standalone Activity +sidebar_label: Nexus Standalone Activity +description: A Nexus Operation can be backed by a Standalone Activity so callers get a typed contract and Namespace boundary around a single durable step. +toc_max_heading_level: 4 +slug: /nexus/standalone-activity +tags: + - Nexus + - Concepts +--- + +:::caution + +Activity-backed Nexus Operations are pre-release and built on the [Temporal Operation Handler](/nexus/temporal-operation-handler). +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A Nexus Operation can be backed by a [Standalone Activity](/standalone-activity) as well as a Workflow. +Starting the Operation starts an Activity Execution that has no parent Workflow, and the Operation completes when that Activity returns. + +Use Standalone Activities when the work behind an Operation is a single durable step rather than a process. Examples might be calling an external API, running a computation, or writing to another system. + +Two things combine here, and it is worth separating them. +An [Activity](/activities) gives that step automatic retries, timeouts, and a durable record of what happened. +Exposing it as a Nexus Operation puts a typed contract and a [Namespace](/namespaces) boundary in front of it, so another team can call it without sharing your code, your deployment, or write access to your Namespace. +Because the Activity carries the durability, the Operation needs no Workflow behind it, and uses fewer [Billable Actions](/cloud/actions-usage#actions-in-workflows) in Temporal Cloud than running the same single step through one. + +For how to implement Activity-backed Operations in each SDK, see [Activity-backed Nexus Operations](/develop/java/nexus/activity-backed-operations). + +## When to use an Activity-backed Operation + +The shape fits work that is one durable step sitting behind a team boundary. +You get all of the following without building any of it yourself: + +- **Durability per call.** Retries on the policy you set, timeouts you control, and a record of every attempt including the last error. +- **Deduplicated starts.** A stable Activity Id means a retried or redelivered request targets the same Activity Execution instead of starting a second one, so you do not need a separate store of processed Ids. +- **Protection from a failing dependency.** Repeated retryable failures trip the [circuit breaker](/nexus/operations#circuit-breaking) rather than piling retries onto a system that is already struggling. +- **A boundary between teams.** Callers get scoped access to named Operations, not write access to your Namespace, and they never see your Task Queues, your Workers, or your implementation. + +## How it works + +The Operation is implemented with a [Temporal Operation Handler](/nexus/temporal-operation-handler). +On start, the handler calls `startActivity` (or the language equivalent) on the Nexus-aware Client and returns that result. +The Operation completes when the Activity returns. + +Required options typically include a stable Activity Id (often derived from the Nexus request Id), a Start-To-Close Timeout, a Task Queue, and an Activity Id conflict policy that makes retries target the same Execution. + +Cancellation behaves like other Activity cancellations: the Activity must heartbeat to receive cancellation promptly. + +## Choose between an Activity and a Workflow + +Prefer an Activity-backed Operation when the work is one durable step. +Prefer a Workflow-backed Operation when the work is a multi-step process, needs Timers or message handlers, or must wait on human input. + +## Related + +- Activity-backed Nexus Operations: + [Go](/develop/go/nexus/activity-backed-operations) · + [Java](/develop/java/nexus/activity-backed-operations) · + [.NET](/develop/dotnet/nexus/activity-backed-operations) · + [Python](/develop/python/nexus/activity-backed-operations) · + [TypeScript](/develop/typescript/nexus/activity-backed-operations) +- [Temporal Operation Handler](/nexus/temporal-operation-handler) — handler model +- [Standalone Activity](/standalone-activity) — Activities without a parent Workflow +- [Microservice Development Walkthrough](/develop/java/nexus/development-walkthrough) — end-to-end Java guide diff --git a/docs/encyclopedia/nexus/nexus.mdx b/docs/encyclopedia/nexus/nexus.mdx index 3f6cf3bd94..b43bbae821 100644 --- a/docs/encyclopedia/nexus/nexus.mdx +++ b/docs/encyclopedia/nexus/nexus.mdx @@ -128,3 +128,22 @@ Each step is a separate, durable Operation with its own retries and failure hand - [Nexus execution debugging](/nexus/execution-debugging) - Bi-directional linking, pending Operations, and tracing. - [Nexus error handling](/nexus/error-handling) - Error types and how they surface in caller Workflows. - [Nexus metrics](/nexus/metrics) - SDK, Cloud, and OSS cluster metrics. + +## Pre-release: the new Nexus developer experience + +A new way of building Nexus Services is in pre-release. It combines a contract-first approach, where one schema generates typed models and Service definitions for every language, with a single handler type that can back an Operation with a Workflow, an Update, or an Activity. + +The APIs are experimental, so expect them to change. + +**Concepts** + +- [Temporal Operation Handler](/nexus/temporal-operation-handler) — single handler type, bidirectional linking across Namespace boundaries +- [Nexus Client Code Generator](/nexus/client-code-generator) — one schema for typed models, validators, and Service definitions +- [Nexus Standalone Activity](/nexus/standalone-activity) — Activity-backed Operations for a single durable step + +**How-to** + +- Temporal Operation Handler: [Go](/develop/go/nexus/temporal-operation-handler) · [Java](/develop/java/nexus/temporal-operation-handler) · [.NET](/develop/dotnet/nexus/temporal-operation-handler) · [Python](/develop/python/nexus/temporal-operation-handler) · [TypeScript](/develop/typescript/nexus/temporal-operation-handler) +- Client Code Generator: [Go](/develop/go/nexus/client-code-generator) · [Java](/develop/java/nexus/client-code-generator) · [Python](/develop/python/nexus/client-code-generator) · [TypeScript](/develop/typescript/nexus/client-code-generator) +- Activity-backed Operations: [Go](/develop/go/nexus/activity-backed-operations) · [Java](/develop/java/nexus/activity-backed-operations) · [.NET](/develop/dotnet/nexus/activity-backed-operations) · [Python](/develop/python/nexus/activity-backed-operations) · [TypeScript](/develop/typescript/nexus/activity-backed-operations) +- [Microservice Development Walkthrough (Java)](/develop/java/nexus/development-walkthrough) — build a Nexus Service end to end, adding one capability at a time diff --git a/docs/encyclopedia/nexus/temporal-operation-handler.mdx b/docs/encyclopedia/nexus/temporal-operation-handler.mdx new file mode 100644 index 0000000000..bdacf1be3e --- /dev/null +++ b/docs/encyclopedia/nexus/temporal-operation-handler.mdx @@ -0,0 +1,75 @@ +--- +id: temporal-operation-handler +title: Temporal Operation Handler +sidebar_label: Temporal Operation Handler +description: The Temporal Operation Handler is a single Nexus handler type that can back an Operation with a Workflow, an Update, or an Activity, with bidirectional linking across Namespace boundaries. +toc_max_heading_level: 4 +slug: /nexus/temporal-operation-handler +tags: + - Nexus + - Concepts +--- + +:::caution + +The Temporal Operation Handler is pre-release. +`TemporalOperationHandler` is marked experimental in the SDKs and may change in backwards-incompatible ways. + +::: + +A [Nexus Service](/nexus/services) publishes Operations that other teams call across [Namespace](/namespaces) boundaries. +`TemporalOperationHandler` is how you implement those Operations. + +It is a single handler type that can back an Operation with any Temporal primitive — start a Workflow, run an Update, start an Activity — or complete the Operation inline with no backing Execution at all. +Whichever you choose, the handler is the same shape, and every Execution it touches is connected back to the caller automatically. + +For language-specific examples, migration from earlier handler APIs, and Worker registration, see [How to use the Temporal Operation Handler](/develop/java/nexus/temporal-operation-handler). + +## What you can do with it + +**Back an Operation with whichever primitive fits the work.** A multi-step process is a Workflow. A single durable step is an [Activity](/activities), with no Workflow wrapped around it — see [Nexus Standalone Activity](/nexus/standalone-activity). A change to something already running is an Update. The caller sees the same Operation contract either way, and you can change your choice later without touching callers. + +**Combine messaging and a backing in one handler.** A handler can Signal a running Workflow to unblock it and then return a different Execution's result for the caller to await. These are not separate handler types you pick between; they compose inside one start handler. + +**Get observability across the Namespace boundary without wiring it.** The Client handed to your handler propagates [bidirectional links](/nexus/execution-debugging#bi-directional-linking) and request Ids on every call it makes. The caller-side and handler-side Executions are connected in the UI and in [Event History](/encyclopedia/event-history), so a single trace crosses the boundary between two teams' Namespaces. + +**Stay idempotent through retries.** The server retries Nexus start requests, and the request Id travels with them. Deriving the backing Execution's Id from it means a retry targets the same Execution rather than starting a second one. + +**Cancel through the same handler.** The Operation token records which kind of Execution backs the Operation, so a cancellation request reaches the right place. The default behavior is usually what you want, and each kind can be overridden when it is not. + +**Grow a handler without rewriting it.** An Operation that starts out completing inline can later gain an async backing, or send a Signal, without changing handler type or breaking its contract. + +## The Nexus-aware Client + +A `TemporalOperationHandler` start handler receives three things: a context, a Client, and the Operation input. + +The Client is what makes the linking automatic, so prefer it over constructing your own inside a handler. +Reaching for your own Client still works, but messages sent that way are not connected back to the caller. + +The Client exposes two kinds of call: + +**Async backings — at most one per Operation invocation.** These determine what the Operation *is*, and their result is delivered to the caller through the Nexus completion callback when the underlying Execution finishes. + +- Start a Workflow — the Operation completes when the Workflow returns +- Update a Workflow — the Operation completes when the Update completes +- Start an Activity — the Operation completes when the Activity returns; see [Nexus Standalone Activity](/nexus/standalone-activity) + +**Sync messaging — as many as you need.** Reach these through the underlying Temporal Client that the Nexus-aware Client exposes. +They take effect during the handler call, still get link propagation, and do not require an async backing. + +- Signal — delivered during the handler call to a Workflow that is already running +- Signal-with-Start — delivers the Signal, starting the Workflow first if it is not already running + +A handler that only sends messages returns a synchronous result, and the Operation completes immediately. + +## Related + +- How to use the Temporal Operation Handler: + [Go](/develop/go/nexus/temporal-operation-handler) · + [Java](/develop/java/nexus/temporal-operation-handler) · + [.NET](/develop/dotnet/nexus/temporal-operation-handler) · + [Python](/develop/python/nexus/temporal-operation-handler) · + [TypeScript](/develop/typescript/nexus/temporal-operation-handler) +- [Nexus Standalone Activity](/nexus/standalone-activity) — Activity-backed Operations +- [Nexus Client Code Generator](/nexus/client-code-generator) — contract-first typed clients +- [Microservice Development Walkthrough](/develop/java/nexus/development-walkthrough) — end-to-end Java guide diff --git a/sidebars.js b/sidebars.js index ba42163641..c28ecc826e 100644 --- a/sidebars.js +++ b/sidebars.js @@ -107,6 +107,8 @@ const developDotnetCategory = { 'develop/dotnet/nexus/quickstart', 'develop/dotnet/nexus/feature-guide', 'develop/dotnet/nexus/standalone-operations', + 'develop/dotnet/nexus/temporal-operation-handler', + 'develop/dotnet/nexus/activity-backed-operations', ], }, { @@ -259,6 +261,9 @@ const developGoCategory = { 'develop/go/nexus/quickstart', 'develop/go/nexus/feature-guide', 'develop/go/nexus/standalone-operations', + 'develop/go/nexus/temporal-operation-handler', + 'develop/go/nexus/client-code-generator', + 'develop/go/nexus/activity-backed-operations', ], }, { @@ -421,6 +426,10 @@ const developJavaCategory = { 'develop/java/nexus/quickstart', 'develop/java/nexus/feature-guide', 'develop/java/nexus/standalone-operations', + 'develop/java/nexus/temporal-operation-handler', + 'develop/java/nexus/client-code-generator', + 'develop/java/nexus/activity-backed-operations', + 'develop/java/nexus/development-walkthrough/index', ], }, { @@ -665,6 +674,9 @@ const developPythonCategory = { 'develop/python/nexus/quickstart', 'develop/python/nexus/feature-guide', 'develop/python/nexus/standalone-operations', + 'develop/python/nexus/temporal-operation-handler', + 'develop/python/nexus/client-code-generator', + 'develop/python/nexus/activity-backed-operations', ], }, { @@ -1077,6 +1089,9 @@ const developTypeScriptCategory = { 'develop/typescript/nexus/quickstart', 'develop/typescript/nexus/feature-guide', 'develop/typescript/nexus/standalone-operations', + 'develop/typescript/nexus/temporal-operation-handler', + 'develop/typescript/nexus/client-code-generator', + 'develop/typescript/nexus/activity-backed-operations', ], }, { @@ -2110,6 +2125,9 @@ module.exports = { }, items: [ 'encyclopedia/nexus/nexus-services', + 'encyclopedia/nexus/temporal-operation-handler', + 'encyclopedia/nexus/nexus-client-code-generator', + 'encyclopedia/nexus/nexus-standalone-activity', 'encyclopedia/nexus/nexus-operations', 'encyclopedia/nexus/standalone-nexus-operation', 'encyclopedia/nexus/nexus-endpoints', diff --git a/src/components/elements/NexusMicroserviceWalkthrough/index.js b/src/components/elements/NexusMicroserviceWalkthrough/index.js new file mode 100644 index 0000000000..90e0323f1f --- /dev/null +++ b/src/components/elements/NexusMicroserviceWalkthrough/index.js @@ -0,0 +1,234 @@ +import React, { + Children, + isValidElement, + useCallback, + useEffect, + useState, +} from 'react'; +import styles from './walkthrough.module.css'; + +/** + * Temporal neon accents — mint, lime, cyan, purple, indigo, pink, etc. + * Each numbered step gets its own color when active. + */ +const NEON = [ + { accent: '#1FF1A5', onAccent: '#0b0b14' }, // mint + { accent: '#C3FF62', onAccent: '#0b0b14' }, // lime + { accent: '#44D5FF', onAccent: '#0b0b14' }, // cyan + { accent: '#B664FF', onAccent: '#ffffff' }, // purple + { accent: '#7F86F1', onAccent: '#0b0b14' }, // soft indigo + { accent: '#FF6BCB', onAccent: '#0b0b14' }, // pink + { accent: '#FF8A3D', onAccent: '#0b0b14' }, // neon orange + { accent: '#5B8CFF', onAccent: '#0b0b14' }, // bright blue + { accent: '#E8FF47', onAccent: '#0b0b14' }, // electric yellow-lime + { accent: '#00E5A8', onAccent: '#0b0b14' }, // aqua +]; + +const PLAIN = { accent: '#7F86F1', onAccent: '#0b0b14' }; + +/** + * Marker for a walkthrough step. Rendered only when selected by the parent. + */ +export function WalkthroughStep({ children }) { + return <>{children}; +} + +/** + * Wraps one or more code blocks the reader is meant to run, so they read as + * commands rather than as sample code to study. Give each wrapped block a + * `title=` as well: the tint is a scanning aid, but the title is what carries + * the meaning for colourblind readers and in the Markdown output. + */ +export function RunThis({ children }) { + return
{children}
; +} +RunThis.displayName = 'RunThis'; +WalkthroughStep.displayName = 'WalkthroughStep'; + +function isWalkthroughStep(child) { + if (!isValidElement(child)) return false; + if (child.type === WalkthroughStep) return true; + return ( + child.type?.displayName === 'WalkthroughStep' || + child.props?.mdxType === 'WalkthroughStep' + ); +} + +function readStepFromUrl(steps) { + if (typeof window === 'undefined') return steps[0]?.props?.id ?? null; + const params = new URLSearchParams(window.location.search); + const fromQuery = params.get('step'); + if (fromQuery && steps.some((s) => s.props.id === fromQuery)) { + return fromQuery; + } + return steps[0]?.props?.id ?? null; +} + +function colorForStep(steps, id) { + // Every tab (including Finish / Tips) gets its own neon by position. + const idx = steps.findIndex((s) => s.props.id === id); + if (idx === -1) return PLAIN; + return NEON[idx % NEON.length]; +} + +/** + * Full-page multi-step walkthrough (Priority & Fairness pattern). + * Deep link: ?step= + */ +export default function NexusMicroserviceWalkthrough({ + children, + title = 'Walkthrough', +}) { + const steps = Children.toArray(children).filter(isWalkthroughStep); + + const [activeId, setActiveId] = useState(() => readStepFromUrl(steps)); + + const selectStep = useCallback( + (id, { push = true } = {}) => { + if (!steps.some((s) => s.props.id === id)) return; + setActiveId(id); + if (typeof window === 'undefined') return; + const url = new URL(window.location.href); + url.searchParams.set('step', id); + url.hash = ''; + if (push) { + window.history.pushState({ step: id }, '', url); + } else { + window.history.replaceState({ step: id }, '', url); + } + window.scrollTo({ top: 0, behavior: 'smooth' }); + }, + [steps], + ); + + useEffect(() => { + const onPop = () => setActiveId(readStepFromUrl(steps)); + window.addEventListener('popstate', onPop); + const current = readStepFromUrl(steps); + if (current) { + const url = new URL(window.location.href); + if (url.searchParams.get('step') !== current) { + url.searchParams.set('step', current); + window.history.replaceState({ step: current }, '', url); + } + } + return () => window.removeEventListener('popstate', onPop); + }, [steps]); + + useEffect(() => { + function onClick(event) { + const anchor = event.target.closest?.('a[href]'); + if (!anchor) return; + const href = anchor.getAttribute('href'); + if (!href) return; + let url; + try { + url = new URL(href, window.location.href); + } catch { + return; + } + if (url.pathname !== window.location.pathname) return; + const step = url.searchParams.get('step'); + if (!step || !steps.some((s) => s.props.id === step)) return; + event.preventDefault(); + selectStep(step, { push: true }); + if (url.hash) { + requestAnimationFrame(() => { + const el = document.getElementById(url.hash.slice(1)); + el?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }); + } + } + document.addEventListener('click', onClick); + return () => document.removeEventListener('click', onClick); + }, [selectStep, steps]); + + const activeIndex = Math.max( + 0, + steps.findIndex((s) => s.props.id === activeId), + ); + const active = steps[activeIndex] ?? steps[0]; + const next = activeIndex < steps.length - 1 ? steps[activeIndex + 1] : null; + + if (!active) return null; + + const numberedIds = steps + .filter((s) => s.props.numbered !== false) + .map((s) => s.props.id); + + function stepNumber(id) { + const idx = numberedIds.indexOf(id); + return idx === -1 ? null : String(idx + 1).padStart(2, '0'); + } + + const activeColor = colorForStep(steps, active.props.id); + const nextColor = next ? colorForStep(steps, next.props.id) : null; + + return ( +
+ + +
+
{active.props.children}
+ + {next ? ( + + ) : null} +
+
+ ); +} diff --git a/src/components/elements/NexusMicroserviceWalkthrough/walkthrough.module.css b/src/components/elements/NexusMicroserviceWalkthrough/walkthrough.module.css new file mode 100644 index 0000000000..e4e9a4e781 --- /dev/null +++ b/src/components/elements/NexusMicroserviceWalkthrough/walkthrough.module.css @@ -0,0 +1,316 @@ +:global([data-theme='dark']) { + --nmw-border: rgba(255, 255, 255, 0.08); + --nmw-nav-inactive: #94a3b8; +} + +:global([data-theme='light']) { + --nmw-border: rgba(0, 0, 0, 0.08); + --nmw-nav-inactive: #64748b; +} + +.shell { + font-family: var(--ifm-font-family-base); + color: var(--ifm-font-color-base); + background: var(--ifm-background-color); + min-height: 60vh; + margin: -0.5rem -1rem 0; + --nmw-accent: #1ff1a5; + --nmw-on-accent: #0b0b14; +} + +@media (min-width: 997px) { + .shell { + margin: -0.5rem -2rem 0; + } +} + +.nav { + position: sticky; + top: var(--ifm-navbar-height); + z-index: 50; + background: var(--ifm-background-color); + border-bottom: 1px solid var(--nmw-border); + display: flex; + align-items: center; + gap: 2px; + padding: 0 20px; + overflow-x: auto; + scrollbar-width: none; +} + +.nav::-webkit-scrollbar { + display: none; +} + +.navBtn { + --nmw-tab-accent: var(--nmw-accent); + --nmw-tab-on-accent: var(--nmw-on-accent); + background: none; + border: 1px solid transparent; + cursor: pointer; + display: inline-flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + color: var(--nmw-nav-inactive); + padding: 9px 11px; + margin: 8px 2px; + white-space: nowrap; + border-radius: 0; + transition: color 0.15s, border-color 0.15s, background-color 0.15s; + font-family: var(--ifm-font-family-base); +} + +.navBtn:hover { + color: var(--nmw-tab-accent); + border-color: color-mix(in srgb, var(--nmw-tab-accent) 55%, transparent); + background: color-mix(in srgb, var(--nmw-tab-accent) 8%, transparent); +} + +.navBtn:hover .navNum { + color: var(--nmw-tab-accent); + background: color-mix(in srgb, var(--nmw-tab-accent) 18%, transparent); +} + +.navBtnActive { + color: var(--nmw-tab-accent); + border-color: var(--nmw-tab-accent); + background: color-mix(in srgb, var(--nmw-tab-accent) 10%, transparent); +} + +.navBtnPlain { + justify-content: center; + padding-top: 14px; + padding-bottom: 14px; +} + +.navBtnPlain .navLabel { + align-self: center; +} + +.navNum { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.15rem; + padding: 0 5px; + font-family: var(--ifm-font-family-monospace); + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + line-height: 1; + border-radius: 0; + background: transparent; + color: var(--nmw-nav-inactive); + transition: background-color 0.15s, color 0.15s; +} + +.navBtnActive .navNum { + color: var(--nmw-tab-accent); + background: color-mix(in srgb, var(--nmw-tab-accent) 20%, transparent); +} + +.navLabel { + font-size: 13px; + font-weight: 500; + line-height: 1.2; +} + +.section { + max-width: 860px; + margin: 0 auto; + padding: 40px 24px 64px; +} + +.stepBody :global(h2):first-of-type { + margin-top: 0; +} + +.stepBody :global(> :first-child) { + margin-top: 0; +} + +.nextBtn { + margin-top: 2rem; + display: inline-flex; + align-items: center; + gap: 10px; + background: var(--nmw-accent); + color: var(--nmw-on-accent); + border: none; + padding: 10px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + border-radius: 0; + font-family: var(--ifm-font-family-base); + transition: filter 0.15s, background-color 0.15s; +} + +.nextBtn:hover { + filter: brightness(1.08); + color: var(--nmw-on-accent); +} + +/* Carries the "advances the walkthrough" meaning as real text: .nextNum is + aria-hidden, so without this the button announces only the step label. */ +.nextLabel { + opacity: 0.85; +} + +.nextNum { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 1.75rem; + height: 1.35rem; + padding: 0 6px; + font-family: var(--ifm-font-family-monospace); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + background: rgba(0, 0, 0, 0.18); + color: var(--nmw-on-accent); +} + +@media (max-width: 640px) { + .nav { + padding: 0 12px; + } + + .navBtn { + padding: 10px 8px 8px; + } + + .navLabel { + font-size: 12px; + } + + .section { + padding: 28px 16px 48px; + } +} + +/* --------------------------------------------------------------------------- + RunThis - commands the reader is meant to run, as opposed to sample code. + Styled as a terminal window so the difference is obvious at a glance: window + chrome with traffic lights, and a darker ground than the #292d3e Palenight + base the sample-code blocks keep. + + #0f1e1d is darker than that base, so every syntax colour gains contrast + rather than losing it. prismThemes.js raised the comment colour to #8a93c8 + to clear 4.5:1 on #292d3e (4.61); on #0f1e1d it reaches 5.79. + + Colour and chrome are never the only signal: every RunThis block also carries + a title, which is what survives into the Markdown output. + --------------------------------------------------------------------------- */ +.runThis { + --nmw-run-bg: #0f1e1d; + --nmw-run-chrome: #2d5c55; + --nmw-run-border: rgba(45, 212, 191, 0.38); + margin-bottom: var(--ifm-leading); +} + +/* Window frame. overflow:hidden clips the
 to the rounded corners. */
+.runThis :global(div[class*='codeBlockContainer']) {
+  background-color: var(--nmw-run-bg);
+  border: 1px solid var(--nmw-run-border);
+  border-radius: 8px;
+  overflow: hidden;
+  box-shadow: 0 6px 18px rgba(0, 0, 0, 0.28);
+}
+
+.runThis :global(div[class*='codeBlockContent']) {
+  background-color: var(--nmw-run-bg);
+}
+
+/* Docusaurus puts background-color in an inline style on the 
 itself, and
+   the 
 paints over the container. An inline declaration can only be beaten
+   by !important, so this one rule needs it. */
+.runThis :global(pre[class*='codeBlock']) {
+  background-color: var(--nmw-run-bg) !important;
+}
+
+/* Title bar becomes the window chrome. */
+.runThis :global(div[class*='codeBlockTitle']) {
+  display: flex;
+  align-items: center;
+  background-color: var(--nmw-run-chrome);
+  border-bottom: 1px solid var(--nmw-run-border);
+  color: #eafaf6;
+  font-family: var(--ifm-font-family-monospace);
+  font-size: 0.78rem;
+  font-weight: 600;
+  letter-spacing: 0.01em;
+  padding-top: 0.45rem;
+  padding-bottom: 0.45rem;
+}
+
+/* Three traffic lights, drawn from one pseudo-element. */
+.runThis :global(div[class*='codeBlockTitle'])::before {
+  content: '';
+  flex: 0 0 auto;
+  width: 0.62rem;
+  height: 0.62rem;
+  margin-right: 2.95rem;
+  border-radius: 50%;
+  background: #ff5f56;
+  box-shadow:
+    0.95rem 0 0 #ffbd2e,
+    1.9rem 0 0 #27c93f;
+}
+
+/* The dots carry no meaning, so keep them out of forced-colours mode. */
+@media (forced-colors: active) {
+  .runThis :global(div[class*='codeBlockTitle'])::before {
+    display: none;
+  }
+}
+
+/* ---------------------------------------------------------------------------
+   Snipsync source links - bind the file path to the code it labels.
+
+   Snipsync emits the path as its own paragraph followed by the code block, so
+   by default the two read as unrelated blocks. These rules turn that paragraph
+   into a header bar joined to the block below, giving sample code the same
+   "one object" shape as a RunThis terminal window, but in the Palenight
+   colours so the two kinds stay distinguishable.
+
+   :has() lets a paragraph be styled for what follows it. Browsers without it
+   fall back to the previous look, which is merely unjoined rather than broken.
+   --------------------------------------------------------------------------- */
+.stepBody p:has(> a:only-child):has(+ div[class*='codeBlockContainer']) {
+  /* A light grey bar reads as a label rather than as more code. The code body
+     below is dark Palenight in both light and dark mode (prismThemes.js uses
+     one theme for both), so this needs no light/dark variant: the joined box
+     looks the same everywhere. */
+  background-color: #dfe2e9;
+  border: 1px solid rgba(0, 0, 0, 0.12);
+  border-bottom: none;
+  border-radius: 8px 8px 0 0;
+  margin-bottom: 0;
+  padding: 0.45rem 1rem;
+  font-family: var(--ifm-font-family-monospace);
+  font-size: 0.76rem;
+  line-height: 1.5;
+  overflow-wrap: anywhere;
+}
+
+.stepBody p:has(> a:only-child):has(+ div[class*='codeBlockContainer']) a {
+  color: #343a46;
+  text-decoration: none;
+}
+
+.stepBody p:has(> a:only-child):has(+ div[class*='codeBlockContainer']) a:hover {
+  color: #11141c;
+  text-decoration: underline;
+}
+
+/* The code block below loses its top rounding so the two form one box. */
+.stepBody p:has(> a:only-child) + div[class*='codeBlockContainer'] {
+  margin-top: 0;
+  border: 1px solid rgba(0, 0, 0, 0.12);
+  border-top: none;
+  border-radius: 0 0 8px 8px;
+}
diff --git a/src/components/elements/index.js b/src/components/elements/index.js
index 0ad882c123..e394697ced 100644
--- a/src/components/elements/index.js
+++ b/src/components/elements/index.js
@@ -12,3 +12,8 @@ export * from './Video'
 export { default as AnnotatedCode } from './AnnotatedCode'
 export { default as PriorityFairnessSimulator } from './PriorityFairnessSimulator'
 export { default as PriorityFairnessWalkthrough } from './PriorityFairnessWalkthrough'
+// WalkthroughStep is deliberately not re-exported here. Demos/EventHistoryWalkthrough already
+// exports that name, and src/components/index.js star-exports both barrels, so exporting it twice
+// makes the name ambiguous and the event-history pages resolve the wrong component. The Nexus
+// walkthrough imports WalkthroughStep straight from ./NexusMicroserviceWalkthrough instead.
+export { default as NexusMicroserviceWalkthrough } from './NexusMicroserviceWalkthrough'