Schedule CHASM Nexus system endpoint tasks on the transfer queue by bergundy · Pull Request #12643 · temporalio/temporal · GitHub
Skip to content

Schedule CHASM Nexus system endpoint tasks on the transfer queue - #12643

Draft
bergundy wants to merge 3 commits into
mainfrom
nexus-chasm-system-endpoint-transfer
Draft

bergundy wants to merge 3 commits into
mainfrom
nexus-chasm-system-endpoint-transfer

Conversation

@bergundy

@bergundy bergundy commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

Supersedes #12615.

Before: Every CHASM Nexus operation invocation and cancelation task was given the endpoint name as its destination, so all of them were routed to the outbound queue and grouped by destination. That includes tasks for the system endpoint, which only makes a fast in-process call to the history service and gets nothing from per-destination grouping.

After: CHASM Nexus tasks for the system endpoint have an empty destination, so they are scheduled on the transfer queue. Tasks for external endpoints are unchanged and stay on the outbound queue.

What changed?

  • Added a taskDestination helper in chasm/lib/nexusoperation that returns an empty string for the system endpoint and the endpoint name otherwise. The invocation and cancelation task transitions (scheduled and rescheduled) use it for TaskAttributes.Destination.
  • Each request is bound by nexusoperation.request.timeout, the remaining operation timeout, and the task executor's context deadline. The task deadline also covers loading the component and saving the result: 10s on the outbound queue, 3s on the transfer queue. So system endpoint requests get less than 3s whatever RequestTimeout is set to. RequestTimeout is looked up by the operation's endpoint name, because system endpoint tasks have no destination. Its description now notes that it must be set below the task deadline to leave time to save the result. The 10s default equals the outbound task deadline.
  • MinRequestTimeout is now checked against the actual time available:
    • If the remaining operation timeout is below it, the operation fails with a timeout, as before.
    • If the time left on the request deadline (task deadline or RequestTimeout) is below it, the request is skipped and the attempt is recorded as a retryable request timeout.
  • DestinationDownError is only returned for tasks on the outbound queue. That's the only queue whose executor unwraps it. On the transfer queue it would turn an attempt that was already saved into a task failure.

Why?

CHASM already categorizes an immediate task with no destination as a transfer task, and the transfer queue executes CHASM side-effect tasks generically, so an empty destination is all that is needed. The transfer queue is a better fit for fast internal RPCs than the outbound queue.

HSM is intentionally not changed. HSM has no transfer task type (generateSubStateMachineTask rejects immediate tasks without a destination), and adding one requires a new persisted task type that older history hosts can't deserialize during a rolling upgrade. External endpoints stay on the outbound queue in both HSM and CHASM to keep per-destination concurrency limits, rate limiting and circuit breaking.

How did you test it?

  • built

  • run locally and tested manually

  • covered by existing tests

  • added new unit test(s)

  • added new functional test(s)

  • go test -tags test_dep ./chasm/lib/nexusoperation/..., including new cases:

    • A retryable error from a system endpoint call (invocation and cancelation) makes Execute return nil.
    • A task deadline below MinRequestTimeout skips the request and records a retryable request timeout.
    • Requests time out at RequestTimeout and at the task deadline (separate cases).
  • TestNexusWorkflowTestSuiteHSM, TestNexusWorkflowTestSuiteCHASM (incl. the system endpoint tests on both code paths) and TestNexusStandaloneTestSuite pass.

  • make lint-code-fast is clean.

Potential risks

  • System endpoint requests now have a budget of less than 3s instead of 10s. Slow in-process handlers will time out and be retried with backoff.

@bergundy
bergundy requested review from a team as code owners October 20, 2026 22:03
@github-actions

github-actions Bot commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor

// per-destination rate limiting, concurrency control and circuit breaking for outgoing HTTP calls. The system endpoint
// is served by an in-process RPC to the history service, so it has no use for any of that and returns an empty
// destination, which places the task on the transfer queue instead.
func taskDestination(endpointName string) string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[med] Behavior: DestinationDownError is returned as a task failure on the transfer queue, even when the attempt was saved successfully

The handlers still wrap retryable call errors unconditionally (operation_tasks.go:208, cancellation_tasks.go:176):

if callErr != nil && isDestinationDown(callErr) {
    saveErr = queueserrors.NewDestinationDownError(callErr.Error(), saveErr)
}

On the outbound queue, CircuitBreakerExecutable.Execute (service/history/queues/executable.go:1041) unwraps this error, so if saveErr == nil the task completes successfully. The transfer queue has no such wrapper. A system endpoint call that fails with a retryable error then returns a non-nil *DestinationDownError wrapping nil, even though saveInvocationResult already recorded the failed attempt and scheduled the backoff. This affects any retryable HandlerError from the system handler and the raw context.DeadlineExceeded (isDestinationDown returns true for both).

executableImpl.HandleErr treats that as an unexpected error. It increments TaskFailures and unexpectedErrorAttempts, logs Fail to process task (and Critical error processing task after a few attempts), and reschedules the task. The next attempt is discarded by Validate because the attempt number changed. So every retryable system endpoint failure produces a spurious task failure metric, a warning log and an extra execution. This is worse than the PR description says ("treated as a regular retryable error"): the work actually succeeded.

Suggestion: only wrap when the task is on the outbound queue, e.g. if callErr != nil && taskDestination(args.endpointName) != "" && isDestinationDown(callErr). Please also add a unit test for the system endpoint retryable-failure path that asserts Execute returns nil.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

[med] Behavior: system endpoint calls now get about 3s instead of 10s, and nexusoperation.request.timeout is silently capped

On the outbound queue, executeChasmSideEffectTask wraps the task in outboundTaskTimeout (10s), which matches the RequestTimeout default (10s). The transfer queue wraps it in taskTimeout (service/history/transfer_queue_task_executor_base.go:36, 3s). That 3s budget covers loading mutable state, loadStartArgs, the call itself and the final UpdateComponent. callCtx := context.WithTimeout(ctx, callTimeout) can't extend the parent deadline, so:

  1. Any nexusoperation.request.timeout above ~3s no longer applies to system endpoint calls. Nothing logs or documents this, and the HSM path still gets 10s.
  2. If the system handler (an in-process history RPC, which can still do persistence writes or hop to another shard) runs out the parent deadline, saveInvocationResult / saveCancellationResult run on the same expired ctx and fail. The attempt is never recorded and no backoff is scheduled. The queue retries the task on its own policy, and it counts toward unexpectedErrorAttempts / DLQ. On the outbound queue the call's 10s budget equalled the task budget, so the same race existed in principle, but with 3s it is much easier to hit under load.

Options: run the CHASM side-effect task in the transfer executor with a longer timeout (or one derived from the task), or give the save step its own context that is not limited by the call's deadline. Either way, please mention the effective timeout change in the PR description.

…or off the outbound queue

Drop the nexusoperation.request.timeout config: the task executor deadline already bounds each
request. Check MinRequestTimeout against the remaining operation timeout (terminal) and the time
left on the task deadline (retryable). Only wrap errors in DestinationDownError for tasks on the
outbound queue, since the transfer queue doesn't unwrap it.
@bergundy
bergundy marked this pull request as draft October 20, 2026 23:21
The request is bound by RequestTimeout, the remaining operation timeout, and the task deadline.
RequestTimeout is the knob for leaving time to save the result within the task deadline.

This branch has not been deployed

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant