Repository navigation
feat(workflow)!: wait for a step's completion outside its Activity - #355
Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars.
|
5cc631f to
94874f2
Compare
94874f2 to
8b09540
Compare
A step that waits for a person returned ErrResultPending and held its Activity open until CompleteActivation closed it with CompleteActivityByID, so the Activity's 365-day timeout had to cover the whole wait. A worker that died while running a step's plugin went unnoticed for a year, and a step left waiting that long had its plugin run again. The Activity now only runs the plugin. ExecuteTaskActivity returns a TaskResult: the step's output, or Suspended when the handler returned ErrResultPending. A suspended step's node waits in the workflow, with no timeout, for the CompleteActivation Workflow Update, which Manager.CompleteActivation sends. The Update's validator rejects a completion for a step that isn't waiting and its handler takes the step out of the waiting set at once, so of several completions for a step exactly one is accepted and the rest get ErrActivationNotPending, as before. A step is registered before its Activity starts, so a completion that arrives while the Activity is still finishing is kept. With the wait out of the Activity, taskActivityTimeout drops to 10 minutes. BREAKING CHANGE: TaskActivationHandler and WorkflowCompletionHandler take the Activity's context as their first argument, ExecuteTaskActivity returns a TaskResult, and CompleteActivation needs Workflow Updates on the Temporal server. Workflows already running when this deploys can't complete their waiting steps. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8b09540 to
3173ba5
Compare
Aravinda-HWK
left a comment
There was a problem hiding this comment.
Note for later: workflow definition versions don't cover engine code changes
This doesn't block the PR. We have no running production workflows yet, so nothing replays on the new code.
Once there are users, though, a change like this would break their running cases. On deploy, those cases replay their history on the new engine code: finished steps would decode their old map result into an empty TaskResult, and waiting steps would accept a completion that never wakes the node.
The versions we already have don't prevent this:
WorkflowDefinition.Versionand the manifest version (_manifest_version) cover changes to the definition (nodes, edges, mappings, templates). Those are already safe, because the definition is saved in each workflow's history.- This PR changes the engine code (
handleTaskNode, the Activity result type). A case started on definition v1 replays the same definition on the new code, so neither version number changes.
For engine changes like this once we have users, we should either:
- gate the change with
workflow.GetVersionso old histories keep the old path:v := workflow.GetVersion(ctx, "wait-outside-activity", workflow.DefaultVersion, 1) if v == workflow.DefaultVersion { // old path } else { // new path }
|
yes, after we get into prod, we need to use |
…its row Core now records a task's task template instead of a copy of its render config (OpenNSW/core#366). Read checks, file bindings, OGA file tokens and task names resolve the render config through the task template in the task's manifest version. Migration 000032 adds task_template_id and drops render_config; rows from before it no longer render. Pins core to its main branch, which also changes the engine's handlers to take the Activity's context (OpenNSW/core#355). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…its row (OpenNSW#946) * feat(tasks)!: resolve a task's render config from the artifacts, not its row Core now records a task's task template instead of a copy of its render config (OpenNSW/core#366). Read checks, file bindings, OGA file tokens and task names resolve the render config through the task template in the task's manifest version. Migration 000032 adds task_template_id and drops render_config; rows from before it no longer render. Pins core to its main branch, which also changes the engine's handlers to take the Activity's context (OpenNSW/core#355). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs(bootstrap): keep stepNamespace's comment on stepNamespace Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(migrations): roll 000032 back to the table's original column order Postgres adds a column only at the end, so the @down rebuilds task_records_v2 to put render_config back in its place. The workflow guide describes the render config's resolution without the old copy. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * refactor(agency): name a case's task nodes in one place CaseHandler.title returns a node's name (task_config title, else render config title, else task template id) instead of handing the raw render config through toNode, matching consignment's taskDisplayName. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Note
For reviewers, lines to review (blank lines not counted): code +93 / −16, plus 65 / −26 comment lines · tests +391 / −125, mostly the existing
ExecuteTaskActivitymocks now returning aTaskResult· docs +19 / −19. Diff against #353.Summary
A step that waits for a person (a form, an officer's review, a payment) returned
ErrResultPendingand held its Activity open untilCompleteActivationclosed it withCompleteActivityByID. The open Activity was the wait, so itsStartToCloseTimeouthad to cover the whole wait: 365 days. That hid two problems:EXTERNAL_REVIEWre-dispatched, a payment restarted).The Activity now only runs the plugin, and the wait moves into the workflow:
ExecuteTaskActivityreturns aTaskResult: the step's output, orSuspendedwhen the handler returnedErrResultPending. Either way the Activity ends.CompleteActivationsends theCompleteActivationWorkflow Update, which settles that future with the step's output. The node then maps the output and moves on as before.An Update rather than a signal, because callers need the answer:
CompleteTaskSteptreats a rejected completion as a stale submission, andHandleTaskCompletiontreats it as "the parent was already woken". The Update's validator rejects a completion for a step that isn't waiting, and its handler takes the step out of the waiting set before anything else runs, so of several completions for one step exactly one is accepted and the rest getErrActivationNotPending, the guarantee Temporal gave when completing an Activity. The Update has no ID of its own: Temporal would answer a second Update with the same ID with the first one's result, so a duplicate would succeed instead of being rejected.A step is registered as waiting before its Activity starts, so a completion that arrives while the Activity is still finishing (a fast callback) is kept. It stops taking completions as soon as it completes, fails or is cancelled, before the node moves on.
With the wait out of the Activity,
taskActivityTimeoutdrops from 365 days to 10 minutes: an attempt whose worker died, or whose call hangs, is retried after 10 minutes under the retry policy from #353. The handlers now get the Activity's context, so a call made with it is cancelled when the attempt times out.Stacked on #353 (base
fix/bound-task-retries): it uses that PR'staskRetryPolicyand timeout constants.Type of Change
Changes Made
workflow/completion.go(new): theCompleteActivationUpdate handler and validator, andexpectCompletion, which registers a step as waiting.workflow/workflow.go:pendingCompletions, the handler registered at workflow start, andhandleTaskNodewaiting on the step's completion when the Activity reportsSuspended.taskActivityTimeoutis 10 minutes.workflow/activities.go:TaskResult, andExecuteTaskActivityturningErrResultPendingintoSuspended. Handlers get the Activity's context.workflow/manager.go:CompleteActivationsends the Update and maps a rejection, or a workflow that has ended or doesn't exist, toErrActivationNotPending.TaskActivationHandlerandWorkflowCompletionHandlertake acontext.Context.ExecuteTaskActivitymocks return aTaskResult(ataskOutputadapter wraps the function-valued ones).workflow/completion_test.gocovers a completion carrying the step's output, a step waiting 400 days without its plugin running again, an unknown and a duplicate completion being rejected, a completion arriving before the Activity returns, and a synchronous step refusing a completion.manager_test.gocovers the UpdateCompleteActivationsends and how its answers map.README.md,taskflow/README.mdandtaskflow/docs/integration-guide.mdshow the handlers with a context, and describe the wait.Breaking:
TaskActivationHandlerisfunc(ctx context.Context, payload TaskPayload) (map[string]any, error)andWorkflowCompletionHandlerisfunc(ctx context.Context, completion WorkflowCompletion) error. A host passes the context on, e.g. toStartTask(ctx, …).ExecuteTaskActivityreturnsTaskResultinstead ofmap[string]any.CompleteActivationneeds Workflow Updates on the Temporal server.Testing
go test -race ./...passes. The real-server tests incomplete_activation_integration_test.go(skipped in CI) pass againsttemporal server start-dev(server 1.30.1): a late completion for an earlier run of a looping node, a step that never existed, a workflow that has ended, and a new test sending five completions for one step at once, of which exactly one is accepted.TestCompleteStepRejectsAnEarlierRunOfALoopingNodealready failed onmain(it expected the first TASK run'sSeqto be 1, but the START node takes 1); it now checks that the later run'sSeqis higher.Checklist
Related Issues
Related to LSFLK/lsf-govtech-tnsw#247 (a hung or crashed step attempt is retried within its timeout, and long waits don't rely on the Activity timeout).
Screenshots/Demo
N/A
Additional Notes
Temporal server: Workflow Updates are generally available from server 1.25 and on by default; nsw-srilanka's compose runs 1.28.3. A deployment that turned them off (
frontend.enableUpdateWorkflowExecution) must turn them back on.nsw-srilanka follows with a core bump and the two handlers taking the context they're given.
This makes acting on a waiting step straightforward. A waiting step is now a wait inside the workflow, not an open Activity, so anything that should interrupt or accompany the wait becomes one more branch alongside the completion. Today the same change would mean cancelling an open Activity and dealing with its retries and late completions. For example:
None of these are in this PR; it only moves the wait so they can be built on it.
🤖 Generated with Claude Code