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..de0ab7597d --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/add-a-standalone-activity.mdx @@ -0,0 +1,174 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +:::note Keep these running + +This step uses the processes started earlier. Leave them running: + +- The development server, from [step 4](/develop/java/nexus/development-walkthrough?step=implement#start-the-development-server). It must have been started with `activity.enableCallbacks=true`, or `notifyRequester` fails with `completion callbacks are not enabled for this namespace`. +- The handler Worker, from [step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker). +- The caller Worker, from [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow). + +::: + +The last Operation, `notifyRequester`, tells the requester whether their approval was `APPROVED` or `DENIED`. It is a single outbound notification with no state and nothing to wait for, so it is backed by a [Standalone Activity](/nexus/standalone-activity), as chosen in [step 3](/develop/java/nexus/development-walkthrough?step=backing). + +## Write the Activity + +The Activity is ordinary and has nothing Nexus-specific in it; a Workflow could call the same Activity unchanged. In this walkthrough it is a placeholder that sends no email. Real logic would call an email provider, push to a notification service, or write to an outbox. + + + +[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`, 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. + +The Activity provides retries, timeouts, and a record of every attempt. The Operation provides the contract, so other teams can trigger the notification without 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, because there is no parent Workflow to scope it. + +The Task Queue is optional and defaults to the one the Operation runs on. Set it to run notifications on their own Worker fleet. + +**Derive the Activity Id from the Nexus request Id so that server retries don't send a second email.** Retried Nexus start requests carry the same request Id, so every retry lands on the same Activity Id and the notification goes out once. Use the same pattern for any externally visible side effect that can't be undone, such as charging a card, posting to a webhook, or creating a ticket. + +To make several Operations share one Activity Execution and all receive its result, derive the Id from the Operation *input* instead. See [Nexus Standalone Activity](/nexus/standalone-activity#required-options). + +## Register the Activity on the Worker + +Register the Activity implementation on the Worker that hosts the Nexus Service. An Activity-backed Operation needs no Workflow registration. + + + +[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 doesn't matter here. For a longer Activity-backed Operation it does: a cancellation request doesn't interrupt an Activity, so an Activity that never heartbeats runs until it completes or times out. Before shipping a long-running Activity-backed Operation, read [Activity cancellation](/activity-execution#cancellation). + +## Run it + +From the command line, this Operation looks like every other one: + + + +```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"} +``` + +On the handler side there is no Workflow, only an Activity Execution 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 handler derived the Activity Id from the Nexus request Id. Running the command again with a new +`--operation-id` creates a second Activity Execution, but a server retry of the *same* request reuses +the first. + +If this fails with `completion callbacks are not enabled for this namespace`, the development +server was started without `activity.enableCallbacks=true`. See +[step 4](/develop/java/nexus/development-walkthrough?step=implement#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..76fa782b83 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/add-messaging.mdx @@ -0,0 +1,256 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +:::note Keep these running + +This step uses the processes started earlier. Leave them running: + +- The development server, from [step 4](/develop/java/nexus/development-walkthrough?step=implement#start-the-development-server). +- The handler Worker, from [step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker). +- The caller Worker, from [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow). + +The commands at the end of this step also act on the approval that +[step 5](/develop/java/nexus/development-walkthrough?step=publish#run-the-operation) left open. + +::: + +The approval blocks waiting for a decision. **Messages** let callers interact with it while it waits. + +This step adds two Operations. What the caller needs back decides which [message](/sending-messages) type each one uses: + +| Operation | Message type | Why this type | +| --- | --- | --- | +| `remindApprover` | Signal | Fire-and-forget. The caller needs the nudge to happen, not a response. | +| `submitDecision` | Update | Changes state *and* returns confirmation that the decision was recorded. | + +If the caller can proceed without hearing back, use a Signal. If the caller needs to know what the message did, use an Update. + +## Add the handlers to the Workflow + +Add a Signal handler that increments the reminder count and an Update handler that records the decision. Recording `APPROVED` or `DENIED` satisfies the condition the Workflow is blocked on, so the Workflow 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`. As 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 and return a synchronous result. The Operation completes 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 real budget is smaller because the clock starts on the caller's side and the request still routes through matching. + +One Signal fits easily. A handler that sends several messages or does slow work may not. Overrunning gives the caller a context deadline exceeded error, which it 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. The Operation completes when the Update completes, and the result arrives through the Nexus completion callback. If the Update is already complete when it returns, such as a retried request or one that failed validation, the result returns synchronously. + +An Update-backed Operation targets a Workflow that already exists, so `submitDecision` fails for a purchase with no running approval. As an async backing, it is limited to one per Operation invocation, though the handler can combine it with sync messaging. + +### Reject a bad Update before it changes anything + +An Update can refuse a request; a Signal cannot, because by the time its handler runs, the Signal is already in history. + +The approval's **validator**, the `@UpdateValidatorMethod` in the Workflow interface above, rejects a second decision for an approval that is already decided. Without it, the later decision would overwrite the first. A rejected Update never runs the handler or reaches Event History, and the caller sees a failed Operation. A validator 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 + +Don't use a message to fetch the final decision. The decision is the result of `requestApproval`, and it reaches the caller without anyone asking. + +When the handler started the approval, Nexus attached a [completion callback](/glossary#nexus-async-completion-callback) to the Workflow. When the Workflow returns, the handler's Namespace delivers the callback to the caller, which records a `NexusOperationCompleted` event in the caller Workflow's history, and the caller Workflow resumes with the decision. See the [asynchronous Operation lifecycle](/nexus/operations#asynchronous-operation-lifecycle). + +Messages change a running approval; they don't read its outcome. Both also stop working once the approval completes: the Temporal Service accepts a Signal or an Update only while the Workflow is 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) expires. + +If more than one system needs the outcome, [step 8](/develop/java/nexus/development-walkthrough?step=send#when-the-approval-already-exists) shows how additional callers attach to a running approval and receive the same decision. + +## Run the messaging Operations + +The handler Worker from step 4 already serves these Operations. Nudge the approval left running at +the end of [step 5](/develop/java/nexus/development-walkthrough?step=publish#run-the-operation). +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 confirmation, including the Workflow's reminder count: + + + +```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} +``` + +The decision unblocks the approval Workflow, which returns and completes the `requestApproval` +Operation from step 5. 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. + + +:::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..1b7382c138 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/call-the-service.mdx @@ -0,0 +1,217 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +Call the approval Operations from a Workflow in the caller Namespace. The caller knows only the Endpoint name and the contract from [step 1](/develop/java/nexus/development-walkthrough?step=contract). + +This caller is Java, like the handler, but it doesn't have to be. See [Call it from another language](#call-it-from-another-language). + +## What the caller gets from the contract + +The caller doesn't hand-write request types, response types, or Operation names. It uses the code generated in [step 2](/develop/java/nexus/development-walkthrough?step=generate): + +- **A Service definition**, so an Operation name is a symbol rather than a string you can misspell. +- **Typed models** for every input and output. +- **Runtime validators** that reject a payload that violates the contract before it reaches the wire. + +So the contract is enforced twice: a field the contract lacks 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 + +In Java, the generated Service interface works directly as a Nexus Service stub. Create it inside the caller Workflow with Operation options, then call its methods as if they were local. This Workflow runs the whole flow, including Operations that later steps add. + + + +[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 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 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(); + } +``` + + + +The caller doesn't know which Task Queue the handler's Worker polls, or that `requestApproval` is backed by a Workflow while `notifyRequester` is backed by a single Activity. The handler team can change a backing, move the handler to another Namespace, or rewrite it in another language, and this caller keeps working. + +Run the caller Worker in another 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 waits until a caller Workflow starts, which happens in +[Finish](/develop/java/nexus/development-walkthrough?step=call-activity#run-the-whole-flow). + +## Await the decision + +`requestApproval` returns the approval Workflow's return value, `APPROVED` or `DENIED`, delivered through the Nexus completion callback when the Workflow finishes. + +The caller doesn't poll. It awaits the Operation, and the wait is durable: the caller Workflow can be evicted and its Worker can restart, and the result still arrives. + +### Set timeouts + +A caller sets three timeouts on a Nexus Operation: + +- **Schedule-to-close** bounds the whole Operation. Set it to how long an approval can legitimately take: a human approval measured in days needs a timeout in days. +- **Schedule-to-start** bounds how long the caller waits for the handler to pick the Operation up. Set it so a handler that is down fails fast, even though the approval itself may run for days. +- **Start-to-close** bounds an asynchronous Operation after it has started. Synchronous Operations such as `remindApprover` 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 + +Each language has a sample that builds this approval Service from the same contract, with both a caller and a handler: + +| Language | Sample | +| --- | --- | +| Go | `{sample repo link}` | +| Python | `{sample repo link}` | +| TypeScript | `{sample repo link}` | + +Each repository's README explains how to run its client. Point it at the Endpoint from [step 5](/develop/java/nexus/development-walkthrough?step=publish) and it drives the Java handler built here, with no changes on either side. The reverse also works: the Java caller can call the handler from any of those samples. + +To generate a caller from this contract yourself, see [Usage](https://github.com/temporalio/nexgen#usage) in the `nexgen` README. + +## Calling without a caller Workflow + +A caller Workflow gives the call durability and lets you orchestrate around it. To run one Operation with nothing to orchestrate, a Client can start it directly as a [Standalone Nexus Operation](/standalone-nexus-operation), using the same contract, handler, and Endpoint. See [Java: Standalone Operations](/develop/java/nexus/standalone-operations). + + +:::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. +- [NexGen](/nexus/nexgen) 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..65b6aaebd7 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/call-the-standalone-activity.mdx @@ -0,0 +1,107 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +Call `notifyRequester` once the decision is final. + +## The caller cannot tell the difference + +The caller Workflow from [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow) calls `notifyRequester` exactly like `requestApproval`: through the same generated stub and Endpoint, with the same type checking and error handling. Nothing reveals that one is backed by an Activity and the other by a Workflow. The handler team could replace the notification Activity with a Workflow that retries across providers and escalates on failure, and no caller would change. + +## Complete the flow + +With every step in place, the caller runs the whole approval: + +1. Call `requestApproval` and await it. The Operation starts the approval Workflow in the handler Namespace, or attaches to one that another Operation already started. +2. While it is pending, other systems call `remindApprover` to nudge the approver and `attachApprovalContext` to add supporting information. +3. Someone calls `submitDecision` with `APPROVED` or `DENIED`. The Update records it, confirms to that caller, and unblocks the approval Workflow. +4. The approval Workflow returns the decision, which resolves the `requestApproval` Operation for every attached caller. +5. The caller calls `notifyRequester` with the decision. + + + +[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(); + + // Runs the whole flow: context attached first, approval requested, approver reminded, decision + // submitted, decision awaited, requester notified. + ApprovalCallerWorkflow workflow = client.newWorkflowStub(ApprovalCallerWorkflow.class, options); + String result = + workflow.runApprovalFlow( + "standing-desk-" + UUID.randomUUID(), + "dana@example.com", + 1250.00, + "Approved in the Q3 ergonomics budget"); + logger.info("Purchase result: {}", result); + } +``` + + + +## Run the whole flow + +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. From the repository root, start the Starter in another terminal: + + + +```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 - Purchase result: APPROVED +``` + +The Starter exits when the run finishes; the two Workers keep running. The handler Worker shows the +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 +``` + +The context is attached *before* the approval is requested, 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 the note created. + +Every call crossed a Namespace boundary, and the caller never learned a Workflow Id, a Task Queue, or what backed any Operation. One Operation started a Workflow, two sent messages to it, one started it if it was not already running, and one started an Activity. To the caller, they were all Operations. + +## Trace it end to end + +Open the caller Workflow in the UI and follow the links. Because the handlers used the injected [Client](/nexus/temporal-operation-handler#the-nexus-aware-client), each Operation links to the Execution it started or messaged in the handler Namespace, so you can move between the two Namespaces in one view. + +## Where to go next + +The Service is complete but minimal. Possible 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 [NexGen](/nexus/nexgen). +- **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..8a1b21de76 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/choose-backing-implementation.mdx @@ -0,0 +1,67 @@ +The contract says nothing about what runs behind an Operation. That is the handler's private decision, and it can change later without touching callers. + +Picking the wrong backing is the most common source of trouble later. This step chooses one for each Operation in the approval Service. + +## Workflow + +Use a Workflow for 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, receives [messages](/sending-messages) while it runs, or must survive a Worker restart partway through. + +A long-lived Workflow that accepts messages is still an ordinary Workflow. What makes it interactive is its message handlers and a Workflow Id you can predict. + +## Standalone Activity + +Use a Standalone Activity for one step with no waiting and 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. + +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 | +| --- | --- | --- | +| `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 | + +An Update is its own backing: the Operation completes when the Update completes, not when the Workflow returns. Signals are not a backing. They are [sync messaging](/nexus/temporal-operation-handler#the-nexus-aware-client), which takes effect and completes the Operation during the handler call. + +The caller can observe the difference. 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. + +The approval has to be a Workflow. It exists for a while, has an identity, and other systems interact with it during its lifetime. An Activity cannot block for a human or receive a Signal. + +The notification is the opposite: a single side effect with nothing to orchestrate. A Workflow would add an Event History and a Workflow Id for no benefit, but the notification touches the outside world and can fail, so it needs the retries an Activity provides. + +## Give the approval Workflow a stable Id + +Derive the approval's Workflow Id from the purchase, not a random value, so that later messages can find it. A caller that knows the item id can then 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. + +Derive it in one place so the two Operations that need it cannot drift 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; + } +``` + + + +In [step 8](/develop/java/nexus/development-walkthrough?step=send), `attachApprovalContext` may start the approval before `requestApproval` is called. Both Operations derive the same Workflow Id from the same item id, so they agree on which Execution they mean. + +:::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..d3bb65c5a3 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/debugging-and-tips.mdx @@ -0,0 +1,96 @@ +Most Nexus problems are wiring problems with a few recognizable symptoms. Start from the symptom. + +## Pre-release build errors + +Two errors mean the development server is missing a setting that is off by default. See +[Start the development server](/develop/java/nexus/development-walkthrough?step=implement#start-the-development-server). + +**`Standalone Nexus operation is disabled`** comes from `temporal nexus operation execute`, +`start`, `describe`, and `result` when the server was started without +`nexusoperation.enableStandalone=true`. Operations called from a caller Workflow are unaffected, so +this only shows up on the command line. + +**`completion callbacks are not enabled for this namespace`** fails only `notifyRequester`, when the +server was started without `activity.enableCallbacks=true`. The other four Operations are unaffected. + +If `temporal nexus --help` doesn't list `operation`, you are on a released CLI rather than the +pre-release build this walkthrough needs. + +## The call hangs and nothing happens + +Check these causes in order: + +1. **No Worker is polling the target Task Queue.** Check that the handler Worker is running and shows as a poller on the Endpoint's target Task Queue. +2. **The Task Queue names don't match.** The Endpoint's target Task Queue and your Worker's Task Queue must be identical strings. A typo queues the request where nobody is listening. +3. **The Operation is waiting as designed.** A human approval with a multi-day schedule-to-close timeout is supposed to sit there. Check in the UI that the Operation is pending rather than stuck. + +## The call fails as unauthorized + +Add the caller Namespace to the Endpoint's allowed caller list. Creating an Endpoint does not authorize anyone to call it, 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 + +Use the [Client](/nexus/temporal-operation-handler#the-nexus-aware-client) that `TemporalOperationHandler` provides for anything that starts or messages an Execution. A Client you construct yourself works, but loses the [bidirectional links](/nexus/execution-debugging#bi-directional-linking) that connect the two Executions. + +## The Operation fails because the Workflow already exists + +By default, starting a Workflow whose Id is already running is an error, so when two Operations derive the same Workflow Id, the second fails. This is intended: a Workflow-backed Operation has only started once its completion callback is attached, so failing beats reporting success to a caller that would wait forever. + +To have the second caller join the running Execution, 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 decision is the result of `requestApproval`, pushed to every caller awaiting the Operation when the Workflow completes. Asking the approval for its status in a loop polls for something already on its way, and stops working once its [Retention Period](/temporal-service/temporal-server#retention-period) expires. + +Use the Operation result for outcomes, and messages to change a running approval. + +### Expecting a late caller to collect a finished result + +Only callers attached to an Operation receive its result. While the approval is running, additional callers can attach with the use-existing conflict policy. After it completes, they can't, so attach before it finishes or have the handler notify them, as `notifyRequester` does. + +### An Activity-backed Operation that will not cancel + +Cancellation doesn't interrupt an Activity. Without heartbeats 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 is. See [Activity cancellation](/activity-execution#cancellation). + +### Duplicate side effects on retry + +The server retries Nexus start requests, so a backing Execution whose Id isn't derived from something stable gets started twice. 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 external side effects, such as notifications. + +### 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, and include in its input whatever the Workflow needs to start. 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 send any number of sync messages but start at most one async backing. Starting a Workflow and a Workflow Update in the same invocation is not valid. Pick one thing for the caller to await. + +### Hand-editing generated code + +Generated files are overwritten on the next run. When a generated name is wrong, fix it with a per-language naming override in the contract. See [Naming & overrides](https://github.com/temporalio/nexgen#naming--overrides) in the `nexgen` README. + +### Letting the contract drift + +Callers and handlers deploy independently, so different contract versions run at the same time. Adding an optional field is safe. Making a field required, removing one, or changing a type 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 has a Worker polling it. + +**Set timeouts to match reality.** A human approval measured in days needs a schedule-to-close timeout in days. + +**Let contract violations be `BAD_REQUEST`.** The generated validators report every violation in one error, so the caller can fix them all at once. See [Nexus error handling](/nexus/error-handling). + +**Use `TemporalOperationHandler` for every Operation.** An Operation that starts synchronous can later gain an async backing 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..63570a9ab7 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/define-the-data-contract.mdx @@ -0,0 +1,200 @@ +:::caution Run the setup first + +This step assumes you read the +[Overview](/develop/java/nexus/development-walkthrough?step=overview) and cloned the sample project. + +::: + +Start with the contract, not the code. + +The contract is the only thing a caller and a handler share. Everything else is private to one side and can change without the other side knowing: each side's language, whether an Operation is backed by a Workflow or an Activity, and which Task Queue the Worker polls. + +## 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, and a TypeScript caller can call a Python handler. The only thing that has to match is the contract both sides were generated from, so each side can use whatever language suits it. + +## Plan the Operations + +Work backwards from what callers need, not from what your Workflow happens to do. + +Callers of the approval Service need to 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 five Operations: + +| Operation | Input | Output | Added in | +| --- | --- | --- | --- | +| `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 | + +Two of these are easy to get wrong: + +**`requestApproval` returns the final decision, not 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, such as a justification or a manager's note, comes from a different system than the approval request, and the two can arrive in either order. Because either one might have to start the approval Workflow, this Operation's input includes the purchase details the Workflow needs 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**, even if it wraps a single value. That lets you add a field later without breaking the wire format. Returning nothing is fine, as `remindApprover` does. + +Keep the types **forward-compatible**. Callers and handlers deploy independently and 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, 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`. It declares no Services or Operations, only data models shared across languages. + +A file is one or the other. 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, so its entry file is a Nexus document with two sections. `services` declares the Service and its Operations, each naming its input and output by reference. `$defs` declares the types those references point at, in ordinary JSON Schema with nothing Nexus-specific, which is what lets the same types generate into four languages. + +:::note How to write a contract of your own + +To write a contract yourself, see +[Definition files](https://github.com/temporalio/nexgen#definition-files) in the `nexgen` README for +the complete rules the generator enforces. + +::: + +Here is the approval contract in full. In [step 2](/develop/java/nexus/development-walkthrough?step=generate) you pass this file to `nexgen` to generate the Java code: + + + +[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: + + # 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: + + 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 + +- [NexGen](/nexus/nexgen) for the contract format, and [Supported JSON Schema features](https://github.com/temporalio/nexgen#supported-json-schema-features) for the supported 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..126485c601 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/generate-code.mdx @@ -0,0 +1,63 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +Generate code from the contract before writing any implementation. Both sides use it: the handler implements the generated Service definition, and the caller invokes Operations through it. + +## What generation produces + +For each type in the contract, [NexGen](/nexus/nexgen) emits a typed model and a runtime validator. For each Service, it also emits a Nexus Service definition with one member per Operation, in the form that is idiomatic for the language. + +- The **handler** implements the Service definition, and the Worker registers that implementation. +- The **caller** invokes Operations through it, so calls are type-checked against the contract. + +The 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 instead of 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. See **[Usage](https://github.com/temporalio/nexgen#usage)** in the `nexgen` README for each language's flags. + +Run this command from the root of the `samples-java` clone you made in +[Before you start](/develop/java/nexus/development-walkthrough?step=overview#clone-the-sample-project). +It reads +[approval.nexusrpc.yaml](https://github.com/temporalio/samples-java/blob/main/core/src/main/java/io/temporal/samples/nexuswalkthrough/approval.nexusrpc.yaml), +the contract from [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 +``` + + + +**Java requires the package name's last segment to match the output directory's name**, which is +why both end in `generatedservice`. The rest of each path is this repository's layout. In your own +project, point `--output` and `--package-name` wherever your source tree keeps generated code. + +Generation **clears the output directory**, so keep the contract outside it, or it is deleted the +first time you regenerate. + +Commit the generated code and regenerate whenever the contract changes. Don't edit the generated files, because the next run overwrites them. When a generated name is wrong for your language, fix it in the contract with a per-language naming override. See **[Naming & overrides](https://github.com/temporalio/nexgen#naming--overrides)** in the `nexgen` README. + +## One contract, four languages + +Run the generator once per language to get that language's typed models, validators, and Service definition. You still write the handler and the caller yourself, but the part both sides must agree on comes from one source. A handler doesn't need to know which languages its callers use, and a caller doesn't need to know which language implements the handler. + +That is what lets the cross-language call in [step 6](/develop/java/nexus/development-walkthrough?step=call) work with no coordination beyond the contract. + +:::tip RESOURCES + +- [NexGen](/nexus/nexgen) for what the generator produces. +- The [`nexgen` README](https://github.com/temporalio/nexgen) for installation, per-language commands, and the supported JSON Schema subset. +- [Data validation](/nexus/nexgen#data-validation) for how the generated validators behave. + +::: 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..41ef2490a1 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/implement-the-service.mdx @@ -0,0 +1,229 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +This step implements the Operation at the center of the Service: back `requestApproval` with the approval Workflow, then start the development server and a Worker that hosts the Service, the Workflow, and the Activities. + +## Write the approval Workflow + +The approval is an ordinary Temporal Workflow, as chosen in [step 3](/develop/java/nexus/development-walkthrough?step=backing#workflow). Nothing in it is Nexus-specific, and a Client could start it directly. The message handlers added in [step 7](/develop/java/nexus/development-walkthrough?step=messaging) make it interactive. + +The Workflow: + +1. Runs an Activity that evaluates whether the request can be auto-decided. In this walkthrough it is a placeholder; real logic would apply policy, check limits, or call a risk service. +2. Runs an Activity that tells a human the request is waiting. Also a placeholder. +3. Blocks until a decision arrives. +4. Returns `APPROVED` or `DENIED`. + +The blocking step is why this is a Workflow. It may wait weeks, across Worker restarts and deployments, and costs nothing while 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 + +Use `TemporalOperationHandler` for every Temporal-backed Operation. It is the entry point to the [Temporal Operation Handler](/nexus/temporal-operation-handler), and it lets an Operation 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 completes when the Workflow returns, delivering the Workflow's return value to the caller. + +Use the injected Client for anything that starts or messages an Execution. It propagates bidirectional links and request Ids, so the caller's Execution and the approval Workflow are connected in the UI with no extra code. A Client you create yourself works, but loses that linking. + + + +[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**. The Operation has only started once its completion callback is attached to a Workflow, so failing loudly beats reporting success to a caller that would then wait forever. The code above already sets `USE_EXISTING` instead, because once another Operation can create the approval first, this one must attach to it. [Step 8](/develop/java/nexus/development-walkthrough?step=send#when-the-approval-already-exists) explains why. + +## Start the development server + +The Worker needs a running Temporal Service and a Namespace to poll. Start the development server +with two dynamic config values that later steps rely on: + + + +```bash title="Run 1 of 3: start the development server" +temporal server start-dev \ + --dynamic-config-value 'nexusoperation.enableStandalone=true' \ + --dynamic-config-value 'activity.enableCallbacks=true' +``` + + + +`nexusoperation.enableStandalone` lets a Client 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. + +In a second terminal, create the Namespace the handler runs in: + + + +```bash title="Run 2 of 3: create the handler Namespace" +temporal operator namespace create --namespace approval-handler-namespace +``` + + + +## Run the Worker + +One Worker hosts the Nexus Service, Workflow, and Activity implementations. It polls `approval-handler-task-queue`, which the Nexus Endpoint targets 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(); + } +``` + + + +The Worker that registers a Nexus Service doesn't have to run the backing Workflow. Larger deployments often split them; see [Nexus patterns](/nexus/patterns). + +In that second terminal, start it from the repository root and leave it running: + + + +```bash title="Run 3 of 3: 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. The Worker runs until you stop it, +so Gradle keeps reporting the task as executing. + +The Worker is polling, but no caller can reach the Service yet. +[Step 5](/develop/java/nexus/development-walkthrough?step=publish) publishes it and runs +`requestApproval` for the first time. + +## Handle failures + +Callers can tell two kinds of failure apart: + +- A **contract violation**, a payload the generated validator rejects, surfaces as `BAD_REQUEST`. Retrying won't help, and the single error lists every violation. +- An **application failure**, where the approval cannot proceed for a business reason, fails the 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..a9678b2ceb --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/overview.mdx @@ -0,0 +1,116 @@ +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 one Nexus capability at each step. + +A [Nexus Service](/evaluate/features/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 + +**A purchase request needs approval before it can proceed.** + +Approval is slow and human-driven, so the system has to survive a wait of minutes or weeks. While a request is pending, other systems might nudge the approver or attach information to the request. When a decision arrives, the requesting system needs the outcome. + +The Service needs to: + +- 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 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 that [NexGen](/nexus/nexgen) generates code for. The reasoning at each step is the same in all of them. + +| 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, and the TypeScript caller can drive the Java handler. [Step 6](/develop/java/nexus/development-walkthrough?step=call) builds the Java caller and 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. + +::: + +## How to follow along + +Two kinds of code block appear in this walkthrough. Hover over either one to get a copy icon. + +**A terminal window is a command to run.** Run this one now to confirm you have a pre-release CLI with server version 1.32.0 or later: + + + +```bash title="Run: check your CLI version" +temporal --version +``` + + + +**Run every terminal command, in order**, or later steps may fail. + +**A block headed by a file path is sample code** to read. You don't need to type it: it's already in the [sample codebase](https://github.com/temporalio/samples-java/tree/main/core/src/main/java/io/temporal/samples/nexuswalkthrough), and the file name links to the file. + +[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; + } +``` + +Command output appears in a plain block with no header: + +``` +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 is relative to the repository root. + + + +```bash title="Run: clone the sample" +git clone https://github.com/temporalio/samples-java.git +cd samples-java +``` + + + +The sample is the finished Service. Each step shows the code it introduces 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 +its development server, which you can [download from the CLI releases page](https://github.com/temporalio/cli/releases). +Check the release's "What's changed" section to make sure it is not a backport. A stock build rejects the Operations this walkthrough +runs. See [Debugging and tips](/develop/java/nexus/development-walkthrough?step=tips) +for what each failure looks like. + +### Run each Operation as you build it + +[Step 4](/develop/java/nexus/development-walkthrough?step=implement) +starts the development server, and [step 5](/develop/java/nexus/development-walkthrough?step=publish) +makes the Service reachable. From then on, each step that adds an Operation ends by running it, so +you see a result before moving on. Those runs use `temporal nexus operation`, which starts a +[Standalone Nexus Operation](/standalone-nexus-operation): the CLI is the caller, so you need no +caller Workflow or caller Worker 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..15ebbf1ee5 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/publish-in-nexus.mdx @@ -0,0 +1,146 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +A Worker is now polling, but callers reach a Service only through a [Nexus Endpoint](/nexus/endpoints), stored in the [Nexus Registry](/nexus/registry). + +An Endpoint routes Operation requests to a target Namespace and Task Queue. Callers address the Endpoint by name and never see the Namespace or Task Queue behind it, so you can move the handler later without changing caller code. + +## Create the Endpoint + +An Endpoint needs 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 the one your Worker uses from [step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker), or requests arrive and nothing picks them up. + +The caller runs in its own Namespace, so the walkthrough crosses a real Namespace boundary. On the +development server, create the caller Namespace and the Endpoint with the CLI: + + + +```bash title="Run 1 of 5: create the caller Namespace and the Endpoint" +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 +``` + + + +`--description-file` is optional. It attaches Markdown that anyone browsing the Registry sees, so +use it to say what the Service is for and who owns it. + +In Temporal Cloud, create the Endpoint 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 spans every Namespace in your Account; in a self-hosted deployment it is scoped to the Temporal Service. + +## Allow caller Namespaces + +:::note This section applies to Temporal Cloud only + +On a development server or a self-hosted Temporal Service, skip to [Verify it is reachable](#verify-it-is-reachable). There is no allowed-caller list to configure. + +::: + +A Temporal Cloud Endpoint **rejects callers that are not on its allowed list**. Add the caller Namespace, including its Account suffix, when you create or edit the Endpoint in the UI or with `tcld`. + +This step is easy to miss because the failure looks like a routing problem. If a call fails as unauthorized and the Endpoint exists, check this list first. + +## Set up credentials + +A development server needs no credentials. Both Namespaces are local and unauthenticated. + +For Temporal Cloud, the caller and handler connect as separate clients, each to its own Namespace, using an API key with access to both or mTLS certificates. With [environment configuration](/develop/environment-configuration), keep one profile per Namespace and select it with an environment variable instead of passing connection options in code. + +## Verify it is reachable + +Before calling the Service, confirm the wiring. Come back to these checks whenever a later call +stops working. + +Check that the Endpoint exists and targets the right Namespace and Task Queue: + + + +```bash title="Run 2 of 5: check the Endpoint exists" +temporal operator nexus endpoint get --name approval-endpoint +``` + + + +Then check that the handler Worker is polling that Task Queue: + + + +```bash title="Run 3 of 5: check the Worker is polling" +temporal task-queue describe \ + --namespace approval-handler-namespace \ + --task-queue approval-handler-task-queue +``` + + + +A Worker that isn't polling is the other common cause of a call that appears to hang: the request is accepted and queued, and nothing serves it. + +## Run the Operation + +The Service is now reachable through the Endpoint. +`temporal nexus operation start` starts a [Standalone Nexus Operation](/standalone-nexus-operation), +which makes the CLI the caller, so you don't need a caller Workflow or caller Worker yet. + +`requestApproval` blocks until someone decides the approval, so use `start`, which returns as soon as +the Operation has started, rather than `execute`, which waits for the result. +The Operation name is `RequestApproval`, not the `requestApproval` key in the contract: the generator +emits the wire name in Pascal case, and the Endpoint routes on that name. + + + +```bash title="Run 4 of 5: 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}' +``` + + + +The handler will say that it is at 87% after running - this is actually output from Gradle, not the workflow, and can be ignored. + +`describe` shows the Operation still open, waiting durably on the Workflow: + + + +```bash title="Run 5 of 5: 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/... +``` + +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). +The link points from the Operation to the approval Workflow in the handler Namespace; the +Nexus-aware Client added it automatically. + +The approval stays open until something decides it. [Step 7](/develop/java/nexus/development-walkthrough?step=messaging) +adds the Operation that does. + +:::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..428c1a6df6 --- /dev/null +++ b/docs/develop/java/nexus/development-walkthrough/_steps/send-messages.mdx @@ -0,0 +1,194 @@ +import { RunThis } from '@site/src/components/elements/NexusMicroserviceWalkthrough'; + +:::note Keep these running + +This step uses the processes started earlier. Leave them running: + +- The development server, from [step 4](/develop/java/nexus/development-walkthrough?step=implement#start-the-development-server). +- The handler Worker, from [step 4](/develop/java/nexus/development-walkthrough?step=implement#run-the-worker). +- The caller Worker, from [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow). + +::: + +To the caller, the messaging Operations are ordinary Operations, called through the same generated stub as `requestApproval` with the same type checking. Whether each one is a Signal or an Update is the handler's private detail. + +## Nudge and decide + +The caller Workflow in [step 6](/develop/java/nexus/development-walkthrough?step=call#call-the-operations-from-a-caller-workflow) calls both. They differ in what comes back: + +- `remindApprover` returns nothing and completes as soon as the Signal is accepted. Accepted means durably recorded, not yet handled; a caller that needs confirmation that the nudge took effect needs an Update. +- `submitDecision` returns confirmation that the decision was recorded. The approval Workflow then completes, which resolves the `requestApproval` Operation the original caller is awaiting. + +Both fail if no approval is running for the purchase. + +## Attach information before the approval exists + +`attachApprovalContext` does not need a running approval. + +Supporting information, such as a justification or a manager's note, comes from a different system than the approval request, so it sometimes arrives first. A plain Signal to a Workflow that does not exist fails, which is wrong for a message meant to be accepted whenever it shows up. + +`attachApprovalContext` uses **Signal-with-Start** instead. If the approval is running, the note is delivered to it. If not, the approval is started and the note is delivered. The caller doesn't need 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 learns only that the note was durably attached. + +### Input needs enough to start the Workflow + +In the [contract](/develop/java/nexus/development-walkthrough?step=contract#plan-the-operations), `attachApprovalContext` takes the item id, requester, amount, *and* note, while `remindApprover` takes only the item id. Because the Operation might start the approval Workflow, it must carry what the Workflow needs to start. + +In general, a with-Start message's input is the union of what the message needs and what the Workflow's start needs. + +## When the approval already exists + +Because `attachApprovalContext` can create the approval, a Workflow with that Id may already be running when someone calls `requestApproval`. Both Operations derive the same 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), so that they agree on which approval they mean. + +**By default, `requestApproval` fails in this situation**, because starting a Workflow whose Id is already running is an error. A Workflow-backed Operation has only started once its completion callback is attached. If a conflicting start did nothing, the Operation would report success with no callback, and the caller would wait for a decision that never arrives. + +That is why `requestApproval` sets the Workflow Id conflict policy to **use-existing**. A start against a running approval then attaches the Operation's completion callback to that Execution, and the caller 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())); + } +``` + + + +This has two effects: + +- **More than one caller can await the same approval.** Every attached callback is notified when the Workflow completes, so several systems can call `requestApproval` for one purchase and all receive the same decision. +- **The Operation is idempotent across separate callers**, not only across server retries of one request. + +Use-existing attaches only to a *running* Execution. If the approval has completed, the call starts a new approval rather than returning the old decision. A system that needs the outcome of a finished approval must have attached while it was open, or be notified by the handler, which the next step adds. + +## Run it in the harder order + +Send the note first, for 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: + + + +```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 + Completed approval-laptop-42 ApprovalWorkflow +``` + +`approval-laptop-42` is the approval from step 5, decided in step 7. + +Now request the approval for the same item. Under the default conflict policy this would fail; with +use-existing it attaches: + + + +```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 call attached its completion +callback to the approval 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: + +- 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`, which 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. + +A start against a completed approval falls to the reuse policy and opens a new one. Set the reuse policy too if you don't want a second approval for the same item. + +::: + + +:::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/sidebars.js b/sidebars.js index 646f29d51e..56072b0d96 100644 --- a/sidebars.js +++ b/sidebars.js @@ -423,6 +423,7 @@ const developJavaCategory = { 'develop/java/nexus/quickstart', 'develop/java/nexus/feature-guide', 'develop/java/nexus/standalone-operations', + 'develop/java/nexus/development-walkthrough/index', ], }, { 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'