mirror of
https://github.com/temporalio/temporal.git
synced 2026-08-30 18:41:49 -07:00
Resend parent workflow asynchronously during standby child completion verification (#11424)
## 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.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,9 @@ const (
|
|||||||
DefaultTimeout = 10 * time.Second * debug.TimeoutMultiplier
|
DefaultTimeout = 10 * time.Second * debug.TimeoutMultiplier
|
||||||
// DefaultLargeTimeout is the default timeout used to make calls
|
// DefaultLargeTimeout is the default timeout used to make calls
|
||||||
DefaultLargeTimeout = time.Minute * debug.TimeoutMultiplier
|
DefaultLargeTimeout = time.Minute * debug.TimeoutMultiplier
|
||||||
|
// DefaultStateSyncTimeout is a backstop for SyncWorkflowState, which ships a workflow's state
|
||||||
|
// across clusters. Callers set the real deadline; the smaller one wins.
|
||||||
|
DefaultStateSyncTimeout = 10 * time.Minute * debug.TimeoutMultiplier
|
||||||
)
|
)
|
||||||
|
|
||||||
type clientImpl struct {
|
type clientImpl struct {
|
||||||
@@ -44,6 +47,10 @@ func (c *clientImpl) createContext(parent context.Context) (context.Context, con
|
|||||||
return context.WithTimeout(parent, c.timeout)
|
return context.WithTimeout(parent, c.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *clientImpl) createContextWithStateSyncTimeout(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(parent, DefaultStateSyncTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *clientImpl) createContextWithLargeTimeout(parent context.Context) (context.Context, context.CancelFunc) {
|
func (c *clientImpl) createContextWithLargeTimeout(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
if parent == nil {
|
if parent == nil {
|
||||||
return context.WithTimeout(context.Background(), c.largeTimeout)
|
return context.WithTimeout(context.Background(), c.largeTimeout)
|
||||||
|
|||||||
@@ -454,7 +454,7 @@ func (c *clientImpl) SyncWorkflowState(
|
|||||||
request *adminservice.SyncWorkflowStateRequest,
|
request *adminservice.SyncWorkflowStateRequest,
|
||||||
opts ...grpc.CallOption,
|
opts ...grpc.CallOption,
|
||||||
) (*adminservice.SyncWorkflowStateResponse, error) {
|
) (*adminservice.SyncWorkflowStateResponse, error) {
|
||||||
ctx, cancel := c.createContext(ctx)
|
ctx, cancel := c.createContextWithStateSyncTimeout(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
return c.client.SyncWorkflowState(ctx, request, opts...)
|
return c.client.SyncWorkflowState(ctx, request, opts...)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ var (
|
|||||||
const (
|
const (
|
||||||
// DefaultTimeout is the default timeout used to make calls
|
// DefaultTimeout is the default timeout used to make calls
|
||||||
DefaultTimeout = time.Second * 30 * debug.TimeoutMultiplier
|
DefaultTimeout = time.Second * 30 * debug.TimeoutMultiplier
|
||||||
|
// DefaultStateSyncTimeout is a backstop for SyncWorkflowState, which ships a workflow's state
|
||||||
|
// across clusters. Callers set the real deadline; the smaller one wins.
|
||||||
|
DefaultStateSyncTimeout = 10 * time.Minute * debug.TimeoutMultiplier
|
||||||
)
|
)
|
||||||
|
|
||||||
type clientImpl struct {
|
type clientImpl struct {
|
||||||
@@ -288,6 +291,10 @@ func (c *clientImpl) createContext(parent context.Context) (context.Context, con
|
|||||||
return context.WithTimeout(parent, c.timeout)
|
return context.WithTimeout(parent, c.timeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *clientImpl) createContextWithStateSyncTimeout(parent context.Context) (context.Context, context.CancelFunc) {
|
||||||
|
return context.WithTimeout(parent, DefaultStateSyncTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
func (c *clientImpl) shardIDFromWorkflowID(namespaceID, workflowID string) int32 {
|
func (c *clientImpl) shardIDFromWorkflowID(namespaceID, workflowID string) int32 {
|
||||||
return common.WorkflowIDToHistoryShard(namespaceID, workflowID, c.numberOfShards)
|
return common.WorkflowIDToHistoryShard(namespaceID, workflowID, c.numberOfShards)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1396,7 +1396,7 @@ func (c *clientImpl) SyncWorkflowState(
|
|||||||
var response *historyservice.SyncWorkflowStateResponse
|
var response *historyservice.SyncWorkflowStateResponse
|
||||||
op := func(ctx context.Context, client historyservice.HistoryServiceClient) error {
|
op := func(ctx context.Context, client historyservice.HistoryServiceClient) error {
|
||||||
var err error
|
var err error
|
||||||
ctx, cancel := c.createContext(ctx)
|
ctx, cancel := c.createContextWithStateSyncTimeout(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
response, err = client.SyncWorkflowState(ctx, request, opts...)
|
response, err = client.SyncWorkflowState(ctx, request, opts...)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -79,6 +79,12 @@ var (
|
|||||||
largeTimeoutContext = map[string]bool{
|
largeTimeoutContext = map[string]bool{
|
||||||
"client.admin.GetReplicationMessages": true,
|
"client.admin.GetReplicationMessages": true,
|
||||||
}
|
}
|
||||||
|
// stateSyncTimeoutContext are the cross-cluster workflow state sync hops, whose callers set a
|
||||||
|
// deadline that can exceed even the large timeout. DefaultStateSyncTimeout is only a backstop.
|
||||||
|
stateSyncTimeoutContext = map[string]bool{
|
||||||
|
"client.admin.SyncWorkflowState": true,
|
||||||
|
"client.history.SyncWorkflowState": true,
|
||||||
|
}
|
||||||
longPollRetryPolicy = map[string]string{
|
longPollRetryPolicy = map[string]string{
|
||||||
"retryableClient.matching.PollWorkflowTaskQueue": "pollPolicy",
|
"retryableClient.matching.PollWorkflowTaskQueue": "pollPolicy",
|
||||||
"retryableClient.matching.PollActivityTaskQueue": "pollPolicy",
|
"retryableClient.matching.PollActivityTaskQueue": "pollPolicy",
|
||||||
@@ -439,6 +445,9 @@ func writeTemplatedMethod(w io.Writer, service service, impl string, m reflect.M
|
|||||||
if largeTimeoutContext[key] {
|
if largeTimeoutContext[key] {
|
||||||
fields["WithLargeTimeout"] = "WithLargeTimeout"
|
fields["WithLargeTimeout"] = "WithLargeTimeout"
|
||||||
}
|
}
|
||||||
|
if stateSyncTimeoutContext[key] {
|
||||||
|
fields["WithLargeTimeout"] = "WithStateSyncTimeout"
|
||||||
|
}
|
||||||
if impl == "client" {
|
if impl == "client" {
|
||||||
if service.name == "history" {
|
if service.name == "history" {
|
||||||
routingOptions := historyRoutingOptions(reqType)
|
routingOptions := historyRoutingOptions(reqType)
|
||||||
@@ -512,7 +521,7 @@ func (c *clientImpl) {{.Method}}(
|
|||||||
var response {{.ResponseType}}
|
var response {{.ResponseType}}
|
||||||
op := func(ctx context.Context, client historyservice.HistoryServiceClient) error {
|
op := func(ctx context.Context, client historyservice.HistoryServiceClient) error {
|
||||||
var err error
|
var err error
|
||||||
ctx, cancel := c.createContext(ctx)
|
ctx, cancel := c.createContext{{or .WithLargeTimeout ""}}(ctx)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
response, err = client.{{.Method}}(ctx, request, opts...)
|
response, err = client.{{.Method}}(ctx, request, opts...)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -2755,7 +2755,22 @@ the number of children greater than or equal to this threshold`,
|
|||||||
ReplicationTaskApplyTimeout = NewGlobalDurationSetting(
|
ReplicationTaskApplyTimeout = NewGlobalDurationSetting(
|
||||||
"history.ReplicationTaskApplyTimeout",
|
"history.ReplicationTaskApplyTimeout",
|
||||||
20*time.Second,
|
20*time.Second,
|
||||||
`ReplicationTaskApplyTimeout is the context timeout for replication task apply`,
|
`ReplicationTaskApplyTimeout is the context timeout for replication task apply, and for the
|
||||||
|
standby CloseExecutionTask's child-to-parent completion verification`,
|
||||||
|
)
|
||||||
|
ParentWorkflowResendMaxInFlight = NewGlobalIntSetting(
|
||||||
|
"history.parentWorkflowResendMaxInFlight",
|
||||||
|
8,
|
||||||
|
`ParentWorkflowResendMaxInFlight caps how many parent workflow resends a shard may run
|
||||||
|
concurrently when EnableAsyncParentWorkflowResend is on. Attempts beyond the cap are dropped; the
|
||||||
|
verifying task retries. This bounds the goroutines this path can create per shard.`,
|
||||||
|
)
|
||||||
|
EnableAsyncParentWorkflowResend = NewGlobalBoolSetting(
|
||||||
|
"history.enableAsyncParentWorkflowResend",
|
||||||
|
false,
|
||||||
|
`EnableAsyncParentWorkflowResend controls whether the standby child-to-parent completion
|
||||||
|
verification resends the parent workflow in the background rather than inline, so the verifying task
|
||||||
|
is not held for the duration of the cross-cluster sync.`,
|
||||||
)
|
)
|
||||||
ReplicationTaskFetcherParallelism = NewGlobalIntSetting(
|
ReplicationTaskFetcherParallelism = NewGlobalIntSetting(
|
||||||
"history.ReplicationTaskFetcherParallelism",
|
"history.ReplicationTaskFetcherParallelism",
|
||||||
|
|||||||
@@ -1117,6 +1117,16 @@ var (
|
|||||||
ReplicationTasksFailed = NewCounterDef("replication_tasks_failed")
|
ReplicationTasksFailed = NewCounterDef("replication_tasks_failed")
|
||||||
ReplicationTasksBackFill = NewCounterDef("replication_tasks_back_fill")
|
ReplicationTasksBackFill = NewCounterDef("replication_tasks_back_fill")
|
||||||
ReplicationTasksBackFillLatency = NewTimerDef("replication_tasks_back_fill_latency")
|
ReplicationTasksBackFillLatency = NewTimerDef("replication_tasks_back_fill_latency")
|
||||||
|
// ParentWorkflowResendAttempts counts parent resends started by standby completion verification.
|
||||||
|
ParentWorkflowResendAttempts = NewCounterDef("parent_workflow_resend_attempts")
|
||||||
|
// ParentWorkflowResendSkipped counts attempts that found a resend for the same parent in flight.
|
||||||
|
ParentWorkflowResendSkipped = NewCounterDef("parent_workflow_resend_skipped")
|
||||||
|
// ParentWorkflowResendFailures counts failed resends. Async resends report failure nowhere else.
|
||||||
|
ParentWorkflowResendFailures = NewCounterDef("parent_workflow_resend_failures")
|
||||||
|
// ParentWorkflowResendLimited counts resends dropped because the shard was at its in-flight cap.
|
||||||
|
ParentWorkflowResendLimited = NewCounterDef("parent_workflow_resend_limited")
|
||||||
|
// ParentWorkflowResendLatency measures a resend: cross-cluster state fetch plus local apply.
|
||||||
|
ParentWorkflowResendLatency = NewTimerDef("parent_workflow_resend_latency")
|
||||||
// ReplicationOrphanedHistoryBranch tracks cases where history branch cleanup was skipped on error
|
// ReplicationOrphanedHistoryBranch tracks cases where history branch cleanup was skipped on error
|
||||||
// to avoid deleting successfully written history. These orphaned branches will be cleaned up by GC.
|
// to avoid deleting successfully written history. These orphaned branches will be cleaned up by GC.
|
||||||
ReplicationOrphanedHistoryBranch = NewCounterDef("replication_orphaned_history_branch")
|
ReplicationOrphanedHistoryBranch = NewCounterDef("replication_orphaned_history_branch")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package verifychildworkflowcompletionrecorded
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
commonpb "go.temporal.io/api/common/v1"
|
commonpb "go.temporal.io/api/common/v1"
|
||||||
"go.temporal.io/api/serviceerror"
|
"go.temporal.io/api/serviceerror"
|
||||||
@@ -15,9 +16,13 @@ import (
|
|||||||
"go.temporal.io/server/common"
|
"go.temporal.io/server/common"
|
||||||
"go.temporal.io/server/common/definition"
|
"go.temporal.io/server/common/definition"
|
||||||
"go.temporal.io/server/common/locks"
|
"go.temporal.io/server/common/locks"
|
||||||
|
"go.temporal.io/server/common/log"
|
||||||
|
"go.temporal.io/server/common/log/tag"
|
||||||
|
"go.temporal.io/server/common/metrics"
|
||||||
"go.temporal.io/server/common/namespace"
|
"go.temporal.io/server/common/namespace"
|
||||||
"go.temporal.io/server/common/persistence/transitionhistory"
|
"go.temporal.io/server/common/persistence/transitionhistory"
|
||||||
"go.temporal.io/server/common/persistence/versionhistory"
|
"go.temporal.io/server/common/persistence/versionhistory"
|
||||||
|
"go.temporal.io/server/common/rpc"
|
||||||
"go.temporal.io/server/service/history/api"
|
"go.temporal.io/server/service/history/api"
|
||||||
"go.temporal.io/server/service/history/consts"
|
"go.temporal.io/server/service/history/consts"
|
||||||
historyi "go.temporal.io/server/service/history/interfaces"
|
historyi "go.temporal.io/server/service/history/interfaces"
|
||||||
@@ -89,6 +94,7 @@ func Invoke(
|
|||||||
request *historyservice.VerifyChildExecutionCompletionRecordedRequest,
|
request *historyservice.VerifyChildExecutionCompletionRecordedRequest,
|
||||||
workflowConsistencyChecker api.WorkflowConsistencyChecker,
|
workflowConsistencyChecker api.WorkflowConsistencyChecker,
|
||||||
shardContext historyi.ShardContext,
|
shardContext historyi.ShardContext,
|
||||||
|
inFlightResends *InFlightResends,
|
||||||
) (*historyservice.VerifyChildExecutionCompletionRecordedResponse, error) {
|
) (*historyservice.VerifyChildExecutionCompletionRecordedResponse, error) {
|
||||||
namespaceID := namespace.ID(request.GetNamespaceId())
|
namespaceID := namespace.ID(request.GetNamespaceId())
|
||||||
if err := api.ValidateNamespaceUUID(namespaceID); err != nil {
|
if err := api.ValidateNamespaceUUID(namespaceID); err != nil {
|
||||||
@@ -107,6 +113,99 @@ func Invoke(
|
|||||||
return nil, errVerify
|
return nil, errVerify
|
||||||
}
|
}
|
||||||
|
|
||||||
|
metricsHandler := shardContext.GetMetricsHandler()
|
||||||
|
|
||||||
|
// The measured resend, run either inline or in the background.
|
||||||
|
resend := func(ctx context.Context) (*historyservice.VerifyChildExecutionCompletionRecordedResponse, error) {
|
||||||
|
metrics.ParentWorkflowResendAttempts.With(metricsHandler).Record(1)
|
||||||
|
startTime := time.Now().UTC()
|
||||||
|
resp, err := resendParentAndVerify(ctx, request, workflowConsistencyChecker, shardContext, namespaceID, versionedTransition, versionHistories, errVerify)
|
||||||
|
metrics.ParentWorkflowResendLatency.With(metricsHandler).Record(time.Since(startTime))
|
||||||
|
if err != nil {
|
||||||
|
recordResendFailure(shardContext, metricsHandler, request, err)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !shardContext.GetConfig().EnableAsyncParentWorkflowResend() {
|
||||||
|
return resend(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The resend can take minutes while the calling standby task's deadline is short, so run it in
|
||||||
|
// the background and let that task retry until the parent lands.
|
||||||
|
//
|
||||||
|
// At most one resend per parent, and at most ParentWorkflowResendMaxInFlight per shard: callers
|
||||||
|
// retry while an earlier resend runs, so without these a stale parent, or a namespace with many
|
||||||
|
// of them, would spawn goroutines without bound.
|
||||||
|
parentKey := definition.NewWorkflowKey(request.NamespaceId, request.ParentExecution.WorkflowId, request.ParentExecution.RunId)
|
||||||
|
claimed, atCapacity := inFlightResends.tryClaim(parentKey, shardContext.GetConfig().ParentWorkflowResendMaxInFlight())
|
||||||
|
if atCapacity {
|
||||||
|
metrics.ParentWorkflowResendLimited.With(metricsHandler).Record(1)
|
||||||
|
shardContext.GetLogger().Warn("Dropped parent workflow resend, shard is at its in-flight limit",
|
||||||
|
tag.WorkflowNamespaceID(request.GetNamespaceId()),
|
||||||
|
tag.NewStringTag("parent-workflow-id", request.ParentExecution.GetWorkflowId()),
|
||||||
|
tag.NewStringTag("parent-run-id", request.ParentExecution.GetRunId()),
|
||||||
|
tag.NewStringTag("child-workflow-id", request.ChildExecution.GetWorkflowId()),
|
||||||
|
tag.NewStringTag("child-run-id", request.ChildExecution.GetRunId()),
|
||||||
|
tag.NewInt("max-in-flight", shardContext.GetConfig().ParentWorkflowResendMaxInFlight()),
|
||||||
|
)
|
||||||
|
return nil, errVerify
|
||||||
|
}
|
||||||
|
if !claimed {
|
||||||
|
metrics.ParentWorkflowResendSkipped.With(metricsHandler).Record(1)
|
||||||
|
return nil, errVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
// The context is detached from the request, which gRPC cancels when this handler returns, and
|
||||||
|
// rooted at the shard lifecycle so the work stops with the shard.
|
||||||
|
resendCtx := rpc.CopyContextValues(shardContext.GetLifecycleContext(), ctx)
|
||||||
|
resendCtx, cancel := context.WithTimeout(resendCtx, shardContext.GetConfig().ReplicationTaskApplyTimeout())
|
||||||
|
go func() {
|
||||||
|
defer cancel()
|
||||||
|
defer inFlightResends.release(parentKey)
|
||||||
|
defer func() {
|
||||||
|
var panicErr error
|
||||||
|
log.CapturePanic(shardContext.GetLogger(), &panicErr)
|
||||||
|
if panicErr != nil {
|
||||||
|
metrics.ParentWorkflowResendFailures.With(metricsHandler).Record(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
_, _ = resend(resendCtx)
|
||||||
|
}()
|
||||||
|
return nil, errVerify
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordResendFailure reports a failed parent resend. On the asynchronous path no caller receives
|
||||||
|
// the error, so these are its only signals.
|
||||||
|
func recordResendFailure(
|
||||||
|
shardContext historyi.ShardContext,
|
||||||
|
metricsHandler metrics.Handler,
|
||||||
|
request *historyservice.VerifyChildExecutionCompletionRecordedRequest,
|
||||||
|
err error,
|
||||||
|
) {
|
||||||
|
metrics.ParentWorkflowResendFailures.With(metricsHandler).Record(1)
|
||||||
|
shardContext.GetLogger().Error("Failed to resend parent workflow for child completion verification",
|
||||||
|
tag.WorkflowNamespaceID(request.GetNamespaceId()),
|
||||||
|
tag.NewStringTag("parent-workflow-id", request.ParentExecution.GetWorkflowId()),
|
||||||
|
tag.NewStringTag("parent-run-id", request.ParentExecution.GetRunId()),
|
||||||
|
tag.NewStringTag("child-workflow-id", request.ChildExecution.GetWorkflowId()),
|
||||||
|
tag.NewStringTag("child-run-id", request.ChildExecution.GetRunId()),
|
||||||
|
tag.Error(err),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resendParentAndVerify pulls the parent workflow's state from the source cluster, applies it, and
|
||||||
|
// re-checks the child's completion. Separate from Invoke so the async path can run it detached.
|
||||||
|
func resendParentAndVerify(
|
||||||
|
ctx context.Context,
|
||||||
|
request *historyservice.VerifyChildExecutionCompletionRecordedRequest,
|
||||||
|
workflowConsistencyChecker api.WorkflowConsistencyChecker,
|
||||||
|
shardContext historyi.ShardContext,
|
||||||
|
namespaceID namespace.ID,
|
||||||
|
versionedTransition *persistencespb.VersionedTransition,
|
||||||
|
versionHistories *historyspb.VersionHistories,
|
||||||
|
errVerify error,
|
||||||
|
) (*historyservice.VerifyChildExecutionCompletionRecordedResponse, error) {
|
||||||
// Resend parent workflow from source cluster
|
// Resend parent workflow from source cluster
|
||||||
|
|
||||||
clusterMetadata := shardContext.GetClusterMetadata()
|
clusterMetadata := shardContext.GetClusterMetadata()
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package verifychildworkflowcompletionrecorded
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"go.temporal.io/server/common/definition"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InFlightResends tracks the parent workflows a shard is currently resending, so concurrent
|
||||||
|
// verification attempts for the same parent do not each pull its state from the source cluster. A
|
||||||
|
// resend clones the parent's full mutable state, and callers retry while one is still running.
|
||||||
|
//
|
||||||
|
// It also caps how many resends a shard runs at once, bounding the goroutines this path creates.
|
||||||
|
//
|
||||||
|
// 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{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryClaim reserves key for the caller. It reports claimed=false when a resend for the same parent
|
||||||
|
// is already running, or atCapacity=true when the shard already has maxInFlight resends. A caller
|
||||||
|
// that claims the key must release it when the resend finishes.
|
||||||
|
func (r *InFlightResends) tryClaim(key definition.WorkflowKey, maxInFlight int) (claimed bool, atCapacity bool) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if _, ok := r.keys[key]; ok {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
if len(r.keys) >= maxInFlight {
|
||||||
|
return false, true
|
||||||
|
}
|
||||||
|
if r.keys == nil {
|
||||||
|
r.keys = make(map[definition.WorkflowKey]struct{})
|
||||||
|
}
|
||||||
|
r.keys[key] = struct{}{}
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *InFlightResends) release(key definition.WorkflowKey) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
delete(r.keys, key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package verifychildworkflowcompletionrecorded
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.temporal.io/server/common/definition"
|
||||||
|
)
|
||||||
|
|
||||||
|
func key(workflowID string) definition.WorkflowKey {
|
||||||
|
return definition.NewWorkflowKey("ns", workflowID, "run")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInFlightResends_ZeroValueIsUsable(t *testing.T) {
|
||||||
|
var r InFlightResends // no constructor
|
||||||
|
claimed, atCapacity := r.tryClaim(key("a"), 8)
|
||||||
|
require.True(t, claimed)
|
||||||
|
require.False(t, atCapacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInFlightResends_DedupesSameParent(t *testing.T) {
|
||||||
|
var r InFlightResends
|
||||||
|
|
||||||
|
claimed, atCapacity := r.tryClaim(key("a"), 8)
|
||||||
|
require.True(t, claimed)
|
||||||
|
require.False(t, atCapacity)
|
||||||
|
|
||||||
|
// Same parent while the first is still held.
|
||||||
|
claimed, atCapacity = r.tryClaim(key("a"), 8)
|
||||||
|
require.False(t, claimed)
|
||||||
|
require.False(t, atCapacity, "a duplicate is not a capacity problem")
|
||||||
|
|
||||||
|
// A different parent is unaffected.
|
||||||
|
claimed, _ = r.tryClaim(key("b"), 8)
|
||||||
|
require.True(t, claimed)
|
||||||
|
|
||||||
|
// Releasing lets the parent be claimed again.
|
||||||
|
r.release(key("a"))
|
||||||
|
claimed, _ = r.tryClaim(key("a"), 8)
|
||||||
|
require.True(t, claimed)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInFlightResends_EnforcesMaxInFlight(t *testing.T) {
|
||||||
|
var r InFlightResends
|
||||||
|
|
||||||
|
claimed, _ := r.tryClaim(key("a"), 2)
|
||||||
|
require.True(t, claimed)
|
||||||
|
claimed, _ = r.tryClaim(key("b"), 2)
|
||||||
|
require.True(t, claimed)
|
||||||
|
|
||||||
|
// Third distinct parent exceeds the cap.
|
||||||
|
claimed, atCapacity := r.tryClaim(key("c"), 2)
|
||||||
|
require.False(t, claimed)
|
||||||
|
require.True(t, atCapacity)
|
||||||
|
|
||||||
|
// A duplicate of an already-held parent still reports dedup, not capacity.
|
||||||
|
claimed, atCapacity = r.tryClaim(key("a"), 2)
|
||||||
|
require.False(t, claimed)
|
||||||
|
require.False(t, atCapacity)
|
||||||
|
|
||||||
|
// Freeing a slot admits the previously rejected parent.
|
||||||
|
r.release(key("b"))
|
||||||
|
claimed, atCapacity = r.tryClaim(key("c"), 2)
|
||||||
|
require.True(t, claimed)
|
||||||
|
require.False(t, atCapacity)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInFlightResends_ZeroMaxRejectsEverything(t *testing.T) {
|
||||||
|
var r InFlightResends
|
||||||
|
claimed, atCapacity := r.tryClaim(key("a"), 0)
|
||||||
|
require.False(t, claimed)
|
||||||
|
require.True(t, atCapacity)
|
||||||
|
}
|
||||||
@@ -282,6 +282,8 @@ type Config struct {
|
|||||||
|
|
||||||
// The following is used by the new RPC replication stack
|
// The following is used by the new RPC replication stack
|
||||||
ReplicationTaskApplyTimeout dynamicconfig.DurationPropertyFn
|
ReplicationTaskApplyTimeout dynamicconfig.DurationPropertyFn
|
||||||
|
EnableAsyncParentWorkflowResend dynamicconfig.BoolPropertyFn
|
||||||
|
ParentWorkflowResendMaxInFlight dynamicconfig.IntPropertyFn
|
||||||
ReplicationTaskFetcherParallelism dynamicconfig.IntPropertyFn
|
ReplicationTaskFetcherParallelism dynamicconfig.IntPropertyFn
|
||||||
ReplicationTaskFetcherAggregationInterval dynamicconfig.DurationPropertyFn
|
ReplicationTaskFetcherAggregationInterval dynamicconfig.DurationPropertyFn
|
||||||
ReplicationTaskFetcherTimerJitterCoefficient dynamicconfig.FloatPropertyFn
|
ReplicationTaskFetcherTimerJitterCoefficient dynamicconfig.FloatPropertyFn
|
||||||
@@ -707,6 +709,8 @@ func NewConfig(
|
|||||||
SendTransientOrSpeculativeWorkflowTaskEvents: dynamicconfig.SendTransientOrSpeculativeWorkflowTaskEvents.Get(dc),
|
SendTransientOrSpeculativeWorkflowTaskEvents: dynamicconfig.SendTransientOrSpeculativeWorkflowTaskEvents.Get(dc),
|
||||||
|
|
||||||
ReplicationTaskApplyTimeout: dynamicconfig.ReplicationTaskApplyTimeout.Get(dc),
|
ReplicationTaskApplyTimeout: dynamicconfig.ReplicationTaskApplyTimeout.Get(dc),
|
||||||
|
EnableAsyncParentWorkflowResend: dynamicconfig.EnableAsyncParentWorkflowResend.Get(dc),
|
||||||
|
ParentWorkflowResendMaxInFlight: dynamicconfig.ParentWorkflowResendMaxInFlight.Get(dc),
|
||||||
ReplicationTaskFetcherParallelism: dynamicconfig.ReplicationTaskFetcherParallelism.Get(dc),
|
ReplicationTaskFetcherParallelism: dynamicconfig.ReplicationTaskFetcherParallelism.Get(dc),
|
||||||
ReplicationTaskFetcherAggregationInterval: dynamicconfig.ReplicationTaskFetcherAggregationInterval.Get(dc),
|
ReplicationTaskFetcherAggregationInterval: dynamicconfig.ReplicationTaskFetcherAggregationInterval.Get(dc),
|
||||||
ReplicationTaskFetcherTimerJitterCoefficient: dynamicconfig.ReplicationTaskFetcherTimerJitterCoefficient.Get(dc),
|
ReplicationTaskFetcherTimerJitterCoefficient: dynamicconfig.ReplicationTaskFetcherTimerJitterCoefficient.Get(dc),
|
||||||
|
|||||||
@@ -137,6 +137,7 @@ type (
|
|||||||
workflowDeleteManager deletemanager.DeleteManager
|
workflowDeleteManager deletemanager.DeleteManager
|
||||||
serializer serialization.Serializer
|
serializer serialization.Serializer
|
||||||
workflowConsistencyChecker api.WorkflowConsistencyChecker
|
workflowConsistencyChecker api.WorkflowConsistencyChecker
|
||||||
|
parentResends verifychildworkflowcompletionrecorded.InFlightResends
|
||||||
chasmEngine chasm.Engine
|
chasmEngine chasm.Engine
|
||||||
versionChecker headers.VersionChecker
|
versionChecker headers.VersionChecker
|
||||||
versionCache worker_versioning.VersionMembershipAndReactivationStatusCache
|
versionCache worker_versioning.VersionMembershipAndReactivationStatusCache
|
||||||
@@ -742,7 +743,7 @@ func (e *historyEngineImpl) VerifyChildExecutionCompletionRecorded(
|
|||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
req *historyservice.VerifyChildExecutionCompletionRecordedRequest,
|
req *historyservice.VerifyChildExecutionCompletionRecordedRequest,
|
||||||
) (*historyservice.VerifyChildExecutionCompletionRecordedResponse, error) {
|
) (*historyservice.VerifyChildExecutionCompletionRecordedResponse, error) {
|
||||||
return verifychildworkflowcompletionrecorded.Invoke(ctx, req, e.workflowConsistencyChecker, e.shardContext)
|
return verifychildworkflowcompletionrecorded.Invoke(ctx, req, e.workflowConsistencyChecker, e.shardContext, &e.parentResends)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *historyEngineImpl) ReplicateEventsV2(
|
func (e *historyEngineImpl) ReplicateEventsV2(
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import (
|
|||||||
wcache "go.temporal.io/server/service/history/workflow/cache"
|
wcache "go.temporal.io/server/service/history/workflow/cache"
|
||||||
"go.temporal.io/server/service/worker/workerdeployment"
|
"go.temporal.io/server/service/worker/workerdeployment"
|
||||||
"go.uber.org/mock/gomock"
|
"go.uber.org/mock/gomock"
|
||||||
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/protobuf/types/known/durationpb"
|
"google.golang.org/protobuf/types/known/durationpb"
|
||||||
"google.golang.org/protobuf/types/known/timestamppb"
|
"google.golang.org/protobuf/types/known/timestamppb"
|
||||||
)
|
)
|
||||||
@@ -2578,6 +2579,8 @@ func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_WorkflowNotExi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_ResendParent() {
|
func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_ResendParent() {
|
||||||
|
// Inline resend: the RPC pulls and re-verifies before returning.
|
||||||
|
s.config.EnableAsyncParentWorkflowResend = func() bool { return false }
|
||||||
|
|
||||||
request := &historyservice.VerifyChildExecutionCompletionRecordedRequest{
|
request := &historyservice.VerifyChildExecutionCompletionRecordedRequest{
|
||||||
NamespaceId: tests.ParentNamespaceID.String(),
|
NamespaceId: tests.ParentNamespaceID.String(),
|
||||||
@@ -2665,6 +2668,112 @@ func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_ResendParent()
|
|||||||
s.NoError(err)
|
s.NoError(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Async resend: the RPC returns the verification error immediately and the pull runs in the
|
||||||
|
// background.
|
||||||
|
func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_ResendParentAsync() {
|
||||||
|
s.config.EnableAsyncParentWorkflowResend = func() bool { return true }
|
||||||
|
|
||||||
|
request := &historyservice.VerifyChildExecutionCompletionRecordedRequest{
|
||||||
|
NamespaceId: tests.ParentNamespaceID.String(),
|
||||||
|
ParentExecution: &commonpb.WorkflowExecution{
|
||||||
|
WorkflowId: tests.WorkflowID,
|
||||||
|
RunId: tests.RunID,
|
||||||
|
},
|
||||||
|
ChildExecution: &commonpb.WorkflowExecution{
|
||||||
|
WorkflowId: "child workflowId",
|
||||||
|
RunId: "child runId",
|
||||||
|
},
|
||||||
|
ParentInitiatedId: 123,
|
||||||
|
ParentInitiatedVersion: 100,
|
||||||
|
ResendParent: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parent is absent locally, so verification fails and a resend is eligible.
|
||||||
|
s.mockExecutionMgr.EXPECT().GetWorkflowExecution(gomock.Any(), gomock.Any()).Return(nil, &serviceerror.NotFound{}).AnyTimes()
|
||||||
|
|
||||||
|
mockClusterMetadata := cluster.NewMockMetadata(s.controller)
|
||||||
|
mockClusterMetadata.EXPECT().GetClusterID().Return(tests.Version).AnyTimes()
|
||||||
|
mockClusterMetadata.EXPECT().GetCurrentClusterName().Return(cluster.TestAlternativeClusterName).AnyTimes()
|
||||||
|
mockClusterMetadata.EXPECT().GetAllClusterInfo().Return(cluster.TestAllClusterInfo).AnyTimes()
|
||||||
|
mockClusterMetadata.EXPECT().ClusterNameForFailoverVersion(true, tests.Version).Return(cluster.TestCurrentClusterName).AnyTimes()
|
||||||
|
s.mockShard.SetClusterMetadata(mockClusterMetadata)
|
||||||
|
|
||||||
|
// Signal when the background resend lands, so assertions do not race the goroutine.
|
||||||
|
syncCalled := make(chan struct{})
|
||||||
|
s.mockShard.Resource.RemoteAdminClient.EXPECT().SyncWorkflowState(gomock.Any(), gomock.Any()).
|
||||||
|
DoAndReturn(func(context.Context, *adminservice.SyncWorkflowStateRequest, ...grpc.CallOption) (*adminservice.SyncWorkflowStateResponse, error) {
|
||||||
|
close(syncCalled)
|
||||||
|
return nil, serviceerror.NewUnavailable("source cluster unavailable")
|
||||||
|
}).Times(1)
|
||||||
|
|
||||||
|
// The RPC itself returns the verification error without waiting for the resend.
|
||||||
|
_, err := s.historyEngine.VerifyChildExecutionCompletionRecorded(metrics.AddMetricsContext(context.Background()), request)
|
||||||
|
var notFound *serviceerror.NotFound
|
||||||
|
s.ErrorAs(err, ¬Found)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-syncCalled:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
s.Fail("background resend did not call SyncWorkflowState")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestVerifyChildExecutionCompletionRecorded_ResendParentDeduped asserts that a second attempt for
|
||||||
|
// the same parent does not start a concurrent resend while the first is still running.
|
||||||
|
func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_ResendParentDeduped() {
|
||||||
|
s.config.EnableAsyncParentWorkflowResend = func() bool { return true }
|
||||||
|
|
||||||
|
request := &historyservice.VerifyChildExecutionCompletionRecordedRequest{
|
||||||
|
NamespaceId: tests.ParentNamespaceID.String(),
|
||||||
|
ParentExecution: &commonpb.WorkflowExecution{
|
||||||
|
WorkflowId: tests.WorkflowID,
|
||||||
|
RunId: tests.RunID,
|
||||||
|
},
|
||||||
|
ChildExecution: &commonpb.WorkflowExecution{
|
||||||
|
WorkflowId: "child workflowId",
|
||||||
|
RunId: "child runId",
|
||||||
|
},
|
||||||
|
ParentInitiatedId: 123,
|
||||||
|
ParentInitiatedVersion: 100,
|
||||||
|
ResendParent: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mockExecutionMgr.EXPECT().GetWorkflowExecution(gomock.Any(), gomock.Any()).Return(nil, &serviceerror.NotFound{}).AnyTimes()
|
||||||
|
|
||||||
|
mockClusterMetadata := cluster.NewMockMetadata(s.controller)
|
||||||
|
mockClusterMetadata.EXPECT().GetClusterID().Return(tests.Version).AnyTimes()
|
||||||
|
mockClusterMetadata.EXPECT().GetCurrentClusterName().Return(cluster.TestAlternativeClusterName).AnyTimes()
|
||||||
|
mockClusterMetadata.EXPECT().GetAllClusterInfo().Return(cluster.TestAllClusterInfo).AnyTimes()
|
||||||
|
mockClusterMetadata.EXPECT().ClusterNameForFailoverVersion(true, tests.Version).Return(cluster.TestCurrentClusterName).AnyTimes()
|
||||||
|
s.mockShard.SetClusterMetadata(mockClusterMetadata)
|
||||||
|
|
||||||
|
// Times(1): only one resend may reach the source. It blocks so the second attempt overlaps.
|
||||||
|
entered := make(chan struct{})
|
||||||
|
release := make(chan struct{})
|
||||||
|
s.mockShard.Resource.RemoteAdminClient.EXPECT().SyncWorkflowState(gomock.Any(), gomock.Any()).
|
||||||
|
DoAndReturn(func(context.Context, *adminservice.SyncWorkflowStateRequest, ...grpc.CallOption) (*adminservice.SyncWorkflowStateResponse, error) {
|
||||||
|
close(entered)
|
||||||
|
<-release
|
||||||
|
return nil, serviceerror.NewUnavailable("source cluster unavailable")
|
||||||
|
}).Times(1)
|
||||||
|
|
||||||
|
ctx := metrics.AddMetricsContext(context.Background())
|
||||||
|
_, err := s.historyEngine.VerifyChildExecutionCompletionRecorded(ctx, request)
|
||||||
|
s.Error(err)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-entered:
|
||||||
|
case <-time.After(10 * time.Second):
|
||||||
|
s.Fail("first resend did not reach the source cluster")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second attempt while the first is in flight: must not start another resend.
|
||||||
|
_, err = s.historyEngine.VerifyChildExecutionCompletionRecorded(ctx, request)
|
||||||
|
s.Error(err)
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_WorkflowClosed() {
|
func (s *engine2Suite) TestVerifyChildExecutionCompletionRecorded_WorkflowClosed() {
|
||||||
|
|
||||||
request := &historyservice.VerifyChildExecutionCompletionRecordedRequest{
|
request := &historyservice.VerifyChildExecutionCompletionRecordedRequest{
|
||||||
|
|||||||
Reference in New Issue
Block a user