Skip to content

Resend parent workflow asynchronously during standby child completion verification - #11424

Merged
michaely520 merged 2 commits into
mainfrom
myoussef/verify-child-completion-context
Aug 7, 2026
Merged

Resend parent workflow asynchronously during standby child completion verification#11424
michaely520 merged 2 commits into
mainfrom
myoussef/verify-child-completion-context

Conversation

@michaely520

@michaely520 michaely520 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Problem

A standby child workflow's CloseExecutionTask verifies its parent recorded the completion, and past MaxLocalParentWorkflowVerificationDuration also resends the parent from the active cluster. That resend is a cross-cluster state sync plus a possibly paginated history backfill — minutes of work — but the whole call was bounded by the standby task's hard-coded 3s taskTimeout. Measured at the active cluster's history shard, the deadline arriving there was 2.999s. So the resend never completed.

Change

  • Run the resend in the background, bounded by ReplicationTaskApplyTimeout — the setting that already bounds this same work on the replication stream. The verify RPC returns immediately and the standby task retries until the parent lands, so it never holds a transfer-queue worker for the sync.
  • The background context is detached from the request (gRPC cancels that when the handler returns) and rooted at the shard lifecycle, so the work stops with the shard.
  • One in-flight resend per parent, tracked in a shard-level map, and at most history.parentWorkflowResendMaxInFlight (8) concurrent resends per shard. Callers retry while an earlier resend runs; without this the test measured 5 full state fetches where 1 suffices. The cap bounds the goroutines this path can create.
  • Lifted two client ceilings so the deadline can actually propagate — admin.SyncWorkflowState (was 10s) and history.SyncWorkflowState (was 30s) now share a DefaultStateSyncTimeout backstop. This also fixes the same 10s cap on the replication stream's ExecutableTaskImpl.SyncState, where production's 5m setting was never reachable either.
  • Metrics: parent_workflow_resend_{attempts,skipped,limited,failures,latency}. Async failures reach no caller, so _failures is the alert signal; _limited means the shard is shedding resends. The background goroutine recovers panics, which would otherwise take down the process.

Also fixes the history-client codegen template, which hardcoded createContext and silently ignored the timeout-tier field.

Rollout

history.enableAsyncParentWorkflowResend, default false. Disabled = the previous inline behavior, bounded by the caller's task deadline. Opt in per cell.

Testing

Unit tests cover the inline, async, and per-parent-dedup paths.

An xdc test (added in abae9cec, removed in b6eb6631) withholds the parent's replication tasks so the child must pull it, asserts the parent is absent from the standby, then stalls the active cluster's SyncWorkflowState for 4 minutes:

--- PASS: TestChildPullsParentWhenParentReplicationIsWithheld (286.03s)
incoming-ctx-remaining: 4m59.999859334s    (2.999s before this change)
sync-state-calls:       1                  (5 without the per-parent guard)
dropped-parent-tasks:   9

During the stall, 4 verify RPCs reached the standby parent shard (t+0, +50s, +101s, +169s) and exactly 1 SyncWorkflowState reached the active cluster: the task retried and the guard turned the retries away.

4 minutes exceeds every deadline that previously bounded this path (3s / 10s / 30s) with ~1m headroom against the 5m setting, so the setting is demonstrably what governs.

To reproduce: git revert b6eb6631, then
go test -tags test_dep ./tests/xdc/ -run TestVerifyChildCompletionParentResendSuite -timeout 30m

Known gaps

  • Concurrency across distinct parents is unbounded (ordinary fan-out, not amplification).
  • When the parent is deleted on the source, the async path can't report that back, so the child retries to the 15m discard instead of finishing immediately. The workflowNotFoundCache TODO already in this file would address it.

@michaely520
michaely520 force-pushed the myoussef/verify-child-completion-context branch 8 times, most recently from f2ceff4 to c444514 Compare August 6, 2026 20:14
@michaely520 michaely520 changed the title Give VerifyChildExecutionCompletionRecorded its own context deadline Resend parent workflow asynchronously during standby child completion verification Aug 6, 2026
@michaely520
michaely520 marked this pull request as ready for review August 6, 2026 21:57
@michaely520
michaely520 requested a review from a team August 6, 2026 21:57
@michaely520
michaely520 requested review from a team as code owners August 6, 2026 21:57
@michaely520 michaely520 added the team/cgs-foundation Require cgs foundation team review label Aug 6, 2026
Comment thread service/history/api/verifychildworkflowcompletionrecorded/api.go Outdated
// The zero value is ready to use; hold it by pointer, never copy it.
type InFlightResends struct {
mu sync.Mutex
keys map[definition.WorkflowKey]struct{}

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.

Maybe consider adding a max size for this map? So we can use it to control the maximum go routine count that are created for resending parent for each shard. Otherwise I am afraid some namespace pattern would cause sudden increase of new go routine created from this code path.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

my hope is this will reduce the outstanding number of go-routines, but as a sanity mechanism added a cap

workflowDeleteManager deletemanager.DeleteManager
serializer serialization.Serializer
workflowConsistencyChecker api.WorkflowConsistencyChecker
parentResends verifychildworkflowcompletionrecorded.InFlightResends

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.

Is this initialized from fx?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

its initialized lazily on the first claim, we just use the zeroed value that is instantiated when the history engine is first instantiated per shard

Comment thread service/history/api/verifychildworkflowcompletionrecorded/api.go Outdated
@michaely520
michaely520 force-pushed the myoussef/verify-child-completion-context branch 3 times, most recently from c02e9a0 to 5fc4b0d Compare August 7, 2026 16:03
Comment thread common/dynamicconfig/constants.go Outdated
@michaely520
michaely520 force-pushed the myoussef/verify-child-completion-context branch from 5fc4b0d to b6eb663 Compare August 7, 2026 18:54
michaely520 and others added 2 commits August 7, 2026 11:59
…ification

A standby CloseExecutionTask verifies that a closed child workflow's parent
recorded its completion. Past MaxLocalParentWorkflowVerificationDuration, that
verification also resends (pulls) the parent workflow's state from the active
cluster: a SyncWorkflowState artifact fetch, then ReplicateVersionedTransition,
which may itself run a paginated GetWorkflowExecutionRawHistoryV2 backfill.

That work can take minutes, but the whole call was bounded by the standby
transfer task's hard-coded 3s taskTimeout, so it could never realistically
finish. Measured at the active cluster's history shard, the deadline arriving at
the innermost hop was 2.999s.

Rather than lengthening the task's deadline -- which would park a host-level
transfer-queue scheduler worker for the duration -- run the resend in the
background and return immediately. The caller's task retries on the standby
not-ready policy and succeeds once the parent lands. The background context is
detached from the request (gRPC cancels that as soon as the handler returns),
rooted at the shard's lifecycle context so the work stops if the shard does, and
bounded by ReplicationTaskApplyTimeout -- the same setting that bounds this exact
work when the replication stream performs it.

Gated on history.enableAsyncParentWorkflowResend, default false. When disabled
the resend runs inline, bounded by the caller's task deadline, as before.

A shard-level guard allows one in-flight resend per parent, and caps concurrent
resends per shard at history.parentWorkflowResendMaxInFlight (8), bounding the
goroutines this path can create. Callers retry while an
earlier resend is still running, and a parent may have many children closing at
once; without the guard the xdc test below measured 5 full artifact fetches where
1 sufficed, each of which clones the parent's mutable state on the active cluster.

Metrics on both paths: parent_workflow_resend_attempts, _skipped, _failures and
_latency. Async failures are returned to no caller, so _failures is the signal to
alert on and _latency is how to tell whether the timeout is sized correctly.

Two client-side ceilings had to be lifted for ReplicationTaskApplyTimeout to reach
the hops the resend makes; each applies min(parent, own timeout), so lifting one
only exposed the next:

  admin.SyncWorkflowState     standby         -> active frontend   (was 10s)
  history.SyncWorkflowState   active frontend -> active history     (was 30s)

They now share a DefaultStateSyncTimeout backstop via a dedicated
stateSyncTimeoutContext codegen tier. No existing constant changes:
admin.DefaultLargeTimeout stays at 1m and GetReplicationMessages keeps using it.

Also fixes the history-client codegen template, which hardcoded createContext and
ignored the timeout-tier field, making tier entries for any history method dead
config.

Incidentally fixes a pre-existing bug: admin.SyncWorkflowState was bounded by
admin.DefaultTimeout (10s) for all callers, including ExecutableTaskImpl.SyncState
on the replication stream, so production's ReplicationTaskApplyTimeout of 5m was
never reachable for that path's cross-cluster fetch either.

Tests: the existing TestVerifyChildExecutionCompletionRecorded_ResendParent now
pins the inline path; new unit tests cover the async path and the per-parent
guard. Adds an xdc test that withholds the parent's replication tasks so the child
must pull the parent, and stalls the active cluster's SyncWorkflowState for 4
minutes to prove the background resend completes and the retrying task verifies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Removes the test scaffolding added in the previous commit, leaving only the
production change:

  - tests/xdc/verify_child_completion_parent_resend_test.go
  - testhooks.HistorySyncWorkflowStateInterceptor
  - the hook invocation in historyEngineImpl.SyncWorkflowState

The test proved the deadline change end to end -- a SyncWorkflowState call
blocked for a measured 240s still completed the parent pull, where every previous
ceiling capped it at 3s, 10s or 30s -- but it cost ~5 minutes of wall clock,
because the only way to hold the active cluster's response open is to actually
sleep. See the previous commit to restore and run it.

common/testing/testhooks/hooks.go and service/history/history_engine.go are
restored byte-for-byte to their pre-change state.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@michaely520
michaely520 force-pushed the myoussef/verify-child-completion-context branch from b6eb663 to 07ef34a Compare August 7, 2026 19:00
@meiliang86 meiliang86 added the reliability-2026 Reliability related changes label Aug 7, 2026
@michaely520
michaely520 merged commit bbc86b7 into main Aug 7, 2026
59 of 61 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

reliability-2026 Reliability related changes team/cgs-foundation Require cgs foundation team review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants