Files
temporal/chasm/chasmtest/test_engine.go
Lina Jodoin 06e5531513 [CHASM] Support WithRequestID on UpdateComponent (#11169)
## What changed?
- `WithRequestID` now applies to `UpdateComponent`, enabling
execution-level idempotency guarding via request ID.
- When a request ID is passed as part of an `UpdateComponent` call (via
API handler), it is persisted upon successful updateFn call. If it is
already present, instead, `UpdateComponent` fails with a
`FailedPrecondition`.
- When a request ID is not passed in, a generated ID is still created
for error tracing purposes, but it is not written to mutable state.
- On transaction close, mutable state will sweep the oldest RequestIDs
(with an `attach_time`) upon hitting the configured limit.
- This will sweep both entries below a configurable max age, as well as
past a certain hard length limit.

## Why?
- Scheduler's `UpdateSchedule` and `PatchSchedule` are implemented as
handlers that persist a signal in V1. V1 signals provide idempotency via
their request IDs. Scheduler V2 doesn't make use of signals, so instead,
it must record request IDs explicitly.
- We reuse the existing map within mutable state.
- We *must* fail with an explicit error (`FailedPrecondition`) instead
of simply returning a zero value (as Signals would on repeated
successful requests). This is because `UpdateComponent` can apply to API
models that include response values (which we don't record, therefore,
we can't return on subsequent calls).

## How did you test it?
- [ ] built
- [ ] run locally and tested manually
- [ ] covered by existing tests
- [x] added new unit test(s)
- [ ] added new functional test(s)

---------

Co-authored-by: Fred Tzeng <fred.tzeng@temporal.io>
2026-08-13 14:51:11 -07:00

767 lines
24 KiB
Go

package chasmtest
import (
"context"
"errors"
"fmt"
"maps"
"sync"
"testing"
"time"
enumspb "go.temporal.io/api/enums/v1"
"go.temporal.io/api/serviceerror"
enumsspb "go.temporal.io/server/api/enums/v1"
persistencespb "go.temporal.io/server/api/persistence/v1"
"go.temporal.io/server/chasm"
"go.temporal.io/server/common/clock"
"go.temporal.io/server/common/cluster"
"go.temporal.io/server/common/definition"
"go.temporal.io/server/common/log"
"go.temporal.io/server/common/metrics"
"go.temporal.io/server/common/namespace"
"go.temporal.io/server/common/testing/testlogger"
"go.temporal.io/server/service/history/tasks"
)
type (
EngineOption func(*Engine)
// Engine is a lightweight in memory CHASM engine for unit tests. It implements
// [chasm.Engine] and supports the full set of conflict and reuse policies, as
// well as blocking [PollComponent] with [NotifyExecution], matching the behavior
// of the production engine as closely as possible without persistence or shard logic.
Engine struct {
t *testing.T
registry *chasm.Registry
logger log.Logger
metrics metrics.Handler
timeSource clock.TimeSource
// currentExecutions maps (namespaceID, businessID) to the latest run (running or closed).
currentExecutions map[businessKey]*execution
// allExecutions maps (namespaceID, businessID, runID) to any run, for lookups by specific RunID.
allExecutions map[runKey]*execution
notifier *executionNotifier
}
execution struct {
key chasm.ExecutionKey
node *chasm.Node
backend *chasm.MockNodeBackend
root chasm.RootComponent
createRequestID string
requestIDs map[string]struct{}
// commitTransition advances the backend's committed transition count.
commitTransition func()
}
businessKey struct {
namespaceID string
businessID string
}
runKey struct {
namespaceID string
businessID string
runID string
}
)
// WithTimeSource overrides the engine's default time source.
// The default is a [clock.EventTimeSource] initialized to [time.Now] at engine creation,
// which gives deterministic, frozen time suitable for most unit tests.
// Pass a *clock.EventTimeSource when tests need to advance time explicitly;
// the caller holds the reference and calls ts.Update(...) directly.
func WithTimeSource(ts clock.TimeSource) EngineOption {
return func(e *Engine) {
e.timeSource = ts
}
}
var defaultTransitionOptions = chasm.TransitionOptions{
ReusePolicy: chasm.BusinessIDReusePolicyAllowDuplicate,
ConflictPolicy: chasm.BusinessIDConflictPolicyFail,
}
var _ chasm.Engine = (*Engine)(nil)
func NewEngine(
t *testing.T,
registry *chasm.Registry,
opts ...EngineOption,
) *Engine {
t.Helper()
ts := clock.NewEventTimeSource()
ts.Update(time.Now())
e := &Engine{
t: t,
registry: registry,
logger: testlogger.NewTestLogger(t, testlogger.FailOnExpectedErrorOnly),
metrics: metrics.NoopMetricsHandler,
timeSource: ts,
currentExecutions: make(map[businessKey]*execution),
allExecutions: make(map[runKey]*execution),
notifier: newExecutionNotifier(),
}
for _, opt := range opts {
opt(e)
}
return e
}
// Tasks returns all physical tasks scheduled for the execution identified by ref, grouped by category.
// Logical tasks accumulate across every [Engine.UpdateComponent], [Engine.StartExecution], and
// [Engine.UpdateWithStartExecution] call on the execution, and convert to physical tasks on CloseTransaction,
// matching what the real engine would deliver to task processors.
func (e *Engine) Tasks(ref chasm.ComponentRef) (map[tasks.Category][]tasks.Task, error) {
exec, err := e.executionForRef(ref)
if err != nil {
return nil, err
}
// Return a shallow copy so callers cannot mutate the internal task lists.
result := make(map[tasks.Category][]tasks.Task, len(exec.backend.TasksByCategory))
maps.Copy(result, exec.backend.TasksByCategory)
return result, nil
}
func (e *Engine) StartExecution(
ctx context.Context,
ref chasm.ComponentRef,
startFn func(chasm.MutableContext) (chasm.RootComponent, error),
opts ...chasm.TransitionOption,
) (chasm.StartExecutionResult, error) {
options := constructTransitionOptions(opts...)
bKey := newBusinessKey(ref.ExecutionKey)
current, hasCurrent := e.currentExecutions[bKey]
if hasCurrent {
if _, ok := current.requestIDs[options.RequestID]; options.RequestID != "" && ok {
serializedRef, err := current.node.Ref(current.root)
if err != nil {
return chasm.StartExecutionResult{}, err
}
return chasm.StartExecutionResult{
ExecutionKey: current.key,
ExecutionRef: serializedRef,
Created: false,
}, nil
}
switch current.backend.GetExecutionState().State {
case enumsspb.WORKFLOW_EXECUTION_STATE_CREATED, enumsspb.WORKFLOW_EXECUTION_STATE_RUNNING:
return e.handleConflictPolicy(ctx, ref, current, startFn, options)
case enumsspb.WORKFLOW_EXECUTION_STATE_COMPLETED:
return e.handleReusePolicy(ctx, ref, current, startFn, options)
default:
return chasm.StartExecutionResult{}, serviceerror.NewInternal(
fmt.Sprintf("unexpected execution state: %v", current.backend.GetExecutionState().State),
)
}
}
return e.startNew(ctx, ref.ExecutionKey, startFn, options.RequestID)
}
func (e *Engine) UpdateWithStartExecution(
ctx context.Context,
ref chasm.ComponentRef,
startFn func(chasm.MutableContext) (chasm.RootComponent, error),
updateFn func(chasm.MutableContext, chasm.Component) error,
opts ...chasm.TransitionOption,
) (chasm.EngineUpdateWithStartExecutionResult, error) {
options := constructTransitionOptions(opts...)
bKey := newBusinessKey(ref.ExecutionKey)
current, hasCurrent := e.currentExecutions[bKey]
if hasCurrent {
switch current.backend.GetExecutionState().State {
case enumsspb.WORKFLOW_EXECUTION_STATE_CREATED, enumsspb.WORKFLOW_EXECUTION_STATE_RUNNING:
serializedRef, err := e.updateComponentInExecution(ctx, current, ref, updateFn, "")
if err != nil {
return chasm.EngineUpdateWithStartExecutionResult{}, err
}
return chasm.EngineUpdateWithStartExecutionResult{
ExecutionKey: current.key,
ExecutionRef: serializedRef,
Created: false,
}, nil
case enumsspb.WORKFLOW_EXECUTION_STATE_COMPLETED:
switch options.ReusePolicy {
case chasm.BusinessIDReusePolicyAllowDuplicate:
case chasm.BusinessIDReusePolicyAllowDuplicateFailedOnly:
if !executionFailed(current) {
return chasm.EngineUpdateWithStartExecutionResult{}, chasm.NewExecutionAlreadyStartedErr(
fmt.Sprintf(
"CHASM execution already completed successfully. BusinessID: %s, RunID: %s, ID Reuse Policy: %v",
ref.BusinessID, current.key.RunID, options.ReusePolicy,
),
current.createRequestID,
current.key.RunID,
)
}
case chasm.BusinessIDReusePolicyRejectDuplicate:
return chasm.EngineUpdateWithStartExecutionResult{}, chasm.NewExecutionAlreadyStartedErr(
fmt.Sprintf(
"CHASM execution already finished. BusinessID: %s, RunID: %s, ID Reuse Policy: %v",
ref.BusinessID, current.key.RunID, options.ReusePolicy,
),
current.createRequestID,
current.key.RunID,
)
default:
return chasm.EngineUpdateWithStartExecutionResult{}, serviceerror.NewInternal(
fmt.Sprintf("unknown business ID reuse policy: %v", options.ReusePolicy),
)
}
default:
return chasm.EngineUpdateWithStartExecutionResult{}, serviceerror.NewInternal(
fmt.Sprintf("unexpected execution state: %v", current.backend.GetExecutionState().State),
)
}
}
return e.startAndUpdateNew(ctx, ref.ExecutionKey, startFn, updateFn, options.RequestID)
}
func (e *Engine) UpdateComponent(
ctx context.Context,
ref chasm.ComponentRef,
updateFn func(chasm.MutableContext, chasm.Component) error,
opts ...chasm.TransitionOption,
) ([]byte, error) {
execution, err := e.executionForRef(ref)
if err != nil {
return nil, err
}
options := constructTransitionOptions(opts...)
if options.RequestID != "" {
if _, ok := execution.requestIDs[options.RequestID]; ok {
return nil, serviceerror.NewFailedPreconditionf(
"request ID %s has already been used for this execution", options.RequestID)
}
}
return e.updateComponentInExecution(ctx, execution, ref, updateFn, options.RequestID)
}
func (e *Engine) ReadComponent(
ctx context.Context,
ref chasm.ComponentRef,
readFn func(chasm.Context, chasm.Component) error,
_ ...chasm.TransitionOption,
) error {
execution, err := e.executionForRef(ref)
if err != nil {
return err
}
chasmCtx := chasm.NewContext(ctx, execution.node)
component, err := execution.node.Component(chasmCtx, ref)
if err != nil {
return err
}
return readFn(chasmCtx, component)
}
// PollComponent waits until the supplied predicate is satisfied when evaluated against the
// component identified by ref. If the predicate is true immediately it returns without blocking.
// Otherwise it subscribes to [NotifyExecution] signals and re evaluates after each one, just
// like the production engine. Returns (nil, nil) if ctx is cancelled, matching the long poll
// timeout semantics of the production engine where the caller is expected to re-poll.
func (e *Engine) PollComponent(
ctx context.Context,
ref chasm.ComponentRef,
predicate func(chasm.Context, chasm.Component) (bool, error),
_ ...chasm.TransitionOption,
) ([]byte, error) {
executionKey := ref.ExecutionKey
checkPredicate := func() ([]byte, bool, error) {
exec, err := e.executionForRef(ref)
if err != nil {
return nil, false, err
}
chasmCtx := chasm.NewContext(ctx, exec.node)
component, err := exec.node.Component(chasmCtx, ref)
if err != nil {
return nil, false, err
}
satisfied, err := predicate(chasmCtx, component)
if err != nil || !satisfied {
return nil, satisfied, err
}
serializedRef, err := exec.node.Ref(component)
return serializedRef, true, err
}
// Evaluate once before subscribing.
serializedRef, satisfied, err := checkPredicate()
if err != nil || satisfied {
return serializedRef, err
}
for {
ch, unsubscribe := e.notifier.subscribe(executionKey)
// Re evaluate while holding the subscription to avoid missing a notification
// that arrives between the failed check above and this subscribe call.
serializedRef, satisfied, err = checkPredicate()
if err != nil || satisfied {
unsubscribe()
return serializedRef, err
}
select {
case <-ch:
unsubscribe()
serializedRef, satisfied, err = checkPredicate()
if err != nil || satisfied {
return serializedRef, err
}
case <-ctx.Done():
unsubscribe()
return nil, nil //nolint:nilerr // nil, nil = long-poll timeout; caller should re-poll
}
}
}
// NotifyExecution wakes up any [PollComponent] callers waiting on the execution.
func (e *Engine) NotifyExecution(key chasm.ExecutionKey) {
e.notifier.notify(key)
}
func (e *Engine) DeleteExecution(
_ context.Context,
ref chasm.ComponentRef,
_ chasm.DeleteExecutionRequest,
) error {
exec, err := e.executionForRef(ref)
if err != nil {
return err
}
rKey := newRunKey(exec.key)
bKey := newBusinessKey(exec.key)
delete(e.allExecutions, rKey)
// Only evict from current if this is still the current run for the businessID.
if cur, ok := e.currentExecutions[bKey]; ok && cur == exec {
delete(e.currentExecutions, bKey)
}
return nil
}
// handleConflictPolicy is called when a StartExecution arrives for a business ID whose
// current run is still running.
func (e *Engine) handleConflictPolicy(
ctx context.Context,
ref chasm.ComponentRef,
current *execution,
startFn func(chasm.MutableContext) (chasm.RootComponent, error),
options chasm.TransitionOptions,
) (chasm.StartExecutionResult, error) {
switch options.ConflictPolicy {
case chasm.BusinessIDConflictPolicyFail:
return chasm.StartExecutionResult{}, chasm.NewExecutionAlreadyStartedErr(
fmt.Sprintf(
"CHASM execution still running. BusinessID: %s, RunID: %s, ID Conflict Policy: %v",
ref.BusinessID, current.key.RunID, options.ConflictPolicy,
),
current.createRequestID,
current.key.RunID,
)
case chasm.BusinessIDConflictPolicyTerminateExisting:
_, _ = current.backend.UpdateWorkflowStateStatus(
enumsspb.WORKFLOW_EXECUTION_STATE_COMPLETED,
enumspb.WORKFLOW_EXECUTION_STATUS_TERMINATED,
)
return e.startNew(ctx, ref.ExecutionKey, startFn, options.RequestID)
case chasm.BusinessIDConflictPolicyUseExisting:
serializedRef, err := current.node.Ref(current.root)
if err != nil {
return chasm.StartExecutionResult{}, err
}
return chasm.StartExecutionResult{
ExecutionKey: current.key,
ExecutionRef: serializedRef,
Created: false,
}, nil
default:
return chasm.StartExecutionResult{}, serviceerror.NewInternal(
fmt.Sprintf("unknown business ID conflict policy: %v", options.ConflictPolicy),
)
}
}
// handleReusePolicy is called when a StartExecution arrives for a business ID whose
// current run is closed or completed.
func (e *Engine) handleReusePolicy(
ctx context.Context,
ref chasm.ComponentRef,
current *execution,
startFn func(chasm.MutableContext) (chasm.RootComponent, error),
options chasm.TransitionOptions,
) (chasm.StartExecutionResult, error) {
switch options.ReusePolicy {
case chasm.BusinessIDReusePolicyAllowDuplicate:
case chasm.BusinessIDReusePolicyAllowDuplicateFailedOnly:
if !executionFailed(current) {
return chasm.StartExecutionResult{}, chasm.NewExecutionAlreadyStartedErr(
fmt.Sprintf(
"CHASM execution already completed successfully. BusinessID: %s, RunID: %s, ID Reuse Policy: %v",
ref.BusinessID, current.key.RunID, options.ReusePolicy,
),
current.createRequestID,
current.key.RunID,
)
}
case chasm.BusinessIDReusePolicyRejectDuplicate:
return chasm.StartExecutionResult{}, chasm.NewExecutionAlreadyStartedErr(
fmt.Sprintf(
"CHASM execution already finished. BusinessID: %s, RunID: %s, ID Reuse Policy: %v",
ref.BusinessID, current.key.RunID, options.ReusePolicy,
),
current.createRequestID,
current.key.RunID,
)
default:
return chasm.StartExecutionResult{}, serviceerror.NewInternal(
fmt.Sprintf("unknown business ID reuse policy: %v", options.ReusePolicy),
)
}
return e.startNew(ctx, ref.ExecutionKey, startFn, options.RequestID)
}
// startNew creates a new execution and registers it as the current run for the business ID.
func (e *Engine) startNew(
ctx context.Context,
key chasm.ExecutionKey,
startFn func(chasm.MutableContext) (chasm.RootComponent, error),
requestID string,
) (chasm.StartExecutionResult, error) {
exec := e.newExecution(key)
exec.createRequestID = requestID
exec.recordRequestID(requestID)
mutableCtx := chasm.NewMutableContext(ctx, exec.node)
root, err := startFn(mutableCtx)
if err != nil {
return chasm.StartExecutionResult{}, err
}
if err := exec.node.SetRootComponent(root); err != nil {
return chasm.StartExecutionResult{}, err
}
if err = exec.closeTransaction(); err != nil {
return chasm.StartExecutionResult{}, err
}
exec.root = root
e.currentExecutions[newBusinessKey(exec.key)] = exec
e.allExecutions[newRunKey(exec.key)] = exec
serializedRef, err := exec.node.Ref(root)
if err != nil {
return chasm.StartExecutionResult{}, err
}
return chasm.StartExecutionResult{
ExecutionKey: exec.key,
ExecutionRef: serializedRef,
Created: true,
}, nil
}
// startAndUpdateNew creates a new execution, applies startFn and updateFn in the same
// transaction, and registers it as the current run for the business ID.
func (e *Engine) startAndUpdateNew(
ctx context.Context,
key chasm.ExecutionKey,
startFn func(chasm.MutableContext) (chasm.RootComponent, error),
updateFn func(chasm.MutableContext, chasm.Component) error,
requestID string,
) (chasm.EngineUpdateWithStartExecutionResult, error) {
exec := e.newExecution(key)
exec.createRequestID = requestID
exec.recordRequestID(requestID)
mutableCtx := chasm.NewMutableContext(ctx, exec.node)
root, err := startFn(mutableCtx)
if err != nil {
return chasm.EngineUpdateWithStartExecutionResult{}, err
}
if err := exec.node.SetRootComponent(root); err != nil {
return chasm.EngineUpdateWithStartExecutionResult{}, err
}
if err := updateFn(mutableCtx, root); err != nil {
return chasm.EngineUpdateWithStartExecutionResult{}, err
}
if err = exec.closeTransaction(); err != nil {
return chasm.EngineUpdateWithStartExecutionResult{}, err
}
exec.root = root
e.currentExecutions[newBusinessKey(exec.key)] = exec
e.allExecutions[newRunKey(exec.key)] = exec
serializedRef, err := exec.node.Ref(root)
if err != nil {
return chasm.EngineUpdateWithStartExecutionResult{}, err
}
return chasm.EngineUpdateWithStartExecutionResult{
ExecutionKey: exec.key,
ExecutionRef: serializedRef,
Created: true,
}, nil
}
func (e *Engine) newExecution(key chasm.ExecutionKey) *execution {
// bsMu (backend state mutex) guards transitionCount and execState, which are shared
// across handler closures. It is separate from MockNodeBackend's internal mu to avoid deadlocks.
var (
bsMu sync.Mutex
transitionCount int64 = 1
execState = persistencespb.WorkflowExecutionState{
State: enumsspb.WORKFLOW_EXECUTION_STATE_CREATED,
Status: enumspb.WORKFLOW_EXECUTION_STATUS_RUNNING,
}
)
backend := &chasm.MockNodeBackend{
// NextTransitionCount is the count the in-flight transaction will commit as.
HandleNextTransitionCount: func() int64 {
bsMu.Lock()
defer bsMu.Unlock()
return transitionCount + 1
},
// CurrentVersionedTransition reflects the latest committed transition count.
HandleCurrentVersionedTransition: func() *persistencespb.VersionedTransition {
bsMu.Lock()
defer bsMu.Unlock()
return &persistencespb.VersionedTransition{
NamespaceFailoverVersion: 1,
TransitionCount: transitionCount,
}
},
HandleGetCurrentVersion: func() int64 { return 1 },
HandleGetWorkflowKey: func() definition.WorkflowKey {
return definition.NewWorkflowKey(key.NamespaceID, key.BusinessID, key.RunID)
},
HandleIsWorkflow: func() bool { return false },
HandleGetNamespaceEntry: func() *namespace.Namespace {
return namespace.NewLocalNamespaceForTest(
&persistencespb.NamespaceInfo{Id: key.NamespaceID, Name: key.NamespaceID},
&persistencespb.NamespaceConfig{},
cluster.TestCurrentClusterName,
)
},
// GetExecutionState returns the current lifecycle state, which CloseTransaction
// uses to decide whether to call UpdateWorkflowStateStatus on the backend.
HandleGetExecutionState: func() *persistencespb.WorkflowExecutionState {
bsMu.Lock()
defer bsMu.Unlock()
return &persistencespb.WorkflowExecutionState{
State: execState.State,
Status: execState.Status,
}
},
// UpdateWorkflowStateStatus is called by CloseTransaction when the root
// component's LifecycleState changes from Running to Completed, Failed, or Terminated.
HandleUpdateWorkflowStateStatus: func(state enumsspb.WorkflowExecutionState, status enumspb.WorkflowExecutionStatus) (bool, error) {
bsMu.Lock()
defer bsMu.Unlock()
changed := execState.State != state || execState.Status != status
execState.State = state
execState.Status = status
return changed, nil
},
}
return &execution{
key: key,
backend: backend,
node: chasm.NewEmptyTree(
e.registry,
e.timeSource,
backend,
chasm.DefaultPathEncoder,
e.logger,
e.metrics,
),
commitTransition: func() {
bsMu.Lock()
defer bsMu.Unlock()
transitionCount++
},
}
}
// closeTransaction closes the execution's transaction and commits its transition count.
func (x *execution) closeTransaction() error {
if _, err := x.node.CloseTransaction(); err != nil {
return err
}
x.commitTransition()
return nil
}
func (x *execution) recordRequestID(requestID string) {
if requestID == "" {
return
}
if x.requestIDs == nil {
x.requestIDs = make(map[string]struct{})
}
x.requestIDs[requestID] = struct{}{}
}
// executionForRef looks up an execution by the ref's RunID when present, or falls back
// to the current run for the business ID when RunID is empty.
func (e *Engine) executionForRef(ref chasm.ComponentRef) (*execution, error) {
if ref.RunID != "" {
exec, ok := e.allExecutions[newRunKey(ref.ExecutionKey)]
if !ok {
return nil, serviceerror.NewNotFound(
fmt.Sprintf("execution not found: namespace=%q business_id=%q run_id=%q", ref.NamespaceID, ref.BusinessID, ref.RunID),
)
}
return exec, nil
}
exec, ok := e.currentExecutions[newBusinessKey(ref.ExecutionKey)]
if !ok {
return nil, serviceerror.NewNotFound(
fmt.Sprintf("execution not found: namespace=%q business_id=%q", ref.NamespaceID, ref.BusinessID),
)
}
return exec, nil
}
func (e *Engine) updateComponentInExecution(
ctx context.Context,
execution *execution,
ref chasm.ComponentRef,
updateFn func(chasm.MutableContext, chasm.Component) error,
requestID string,
) ([]byte, error) {
mutableCtx := chasm.NewMutableContext(ctx, execution.node)
component, err := execution.node.Component(mutableCtx, ref)
if err != nil {
return nil, err
}
if err := updateFn(mutableCtx, component); err != nil {
return nil, err
}
if err = execution.closeTransaction(); err != nil {
return nil, err
}
execution.recordRequestID(requestID)
serializedRef, err := mutableCtx.Ref(component)
if errors.As(err, new(*serviceerror.NotFound)) {
return nil, nil
}
return serializedRef, err
}
// refForComponent looks up the ComponentRef for a component instance by scanning
// all executions. It works because Node.CloseTransaction (called after every mutation)
// runs syncSubComponents, which populates the node's valueToNode map for all
// subcomponents. Returns an error if the component is not found in any execution.
func (e *Engine) refForComponent(component chasm.Component) (chasm.ComponentRef, error) {
for _, exec := range e.allExecutions {
serialized, err := exec.node.Ref(component)
if err != nil {
if errors.As(err, new(*serviceerror.NotFound)) {
continue // component not registered in this execution's node
}
return chasm.ComponentRef{}, err
}
return chasm.DeserializeComponentRef(serialized)
}
return chasm.ComponentRef{}, fmt.Errorf("component %T not found in any execution managed by this engine", component)
}
func constructTransitionOptions(opts ...chasm.TransitionOption) chasm.TransitionOptions {
options := defaultTransitionOptions
for _, opt := range opts {
opt(&options)
}
// NOTE: TransitionOptions.Speculative is intentionally not implemented here. It is also
// unimplemented in the production engine (see the TODO in service/history/chasm_engine.go).
return options
}
// executionFailed reports whether a closed execution ended in a failure state
// (failed, terminated, cancelled, or timed out). This drives the
// [chasm.BusinessIDReusePolicyAllowDuplicateFailedOnly] reuse policy check.
func executionFailed(exec *execution) bool {
return exec.backend.GetExecutionState().Status != enumspb.WORKFLOW_EXECUTION_STATUS_COMPLETED
}
func newBusinessKey(key chasm.ExecutionKey) businessKey {
return businessKey{namespaceID: key.NamespaceID, businessID: key.BusinessID}
}
func newRunKey(key chasm.ExecutionKey) runKey {
return runKey{namespaceID: key.NamespaceID, businessID: key.BusinessID, runID: key.RunID}
}
// executionNotifier allows [PollComponent] callers to subscribe to state change
// signals for a given execution. notify closes the channel for all current
// subscribers and each subscriber must resubscribe after being woken.
type executionNotifier struct {
mu sync.Mutex
subscribers map[chasm.ExecutionKey][]chan struct{}
}
func newExecutionNotifier() *executionNotifier {
return &executionNotifier{
subscribers: make(map[chasm.ExecutionKey][]chan struct{}),
}
}
// subscribe returns a channel that will be closed on the next notify call for key,
// and an unsubscribe function that must be called when the caller is done waiting.
func (n *executionNotifier) subscribe(key chasm.ExecutionKey) (<-chan struct{}, func()) {
ch := make(chan struct{})
n.mu.Lock()
n.subscribers[key] = append(n.subscribers[key], ch)
n.mu.Unlock()
unsubscribed := false
unsubscribe := func() {
n.mu.Lock()
defer n.mu.Unlock()
if unsubscribed {
return
}
unsubscribed = true
subs := n.subscribers[key]
for i, s := range subs {
if s == ch {
n.subscribers[key] = append(subs[:i], subs[i+1:]...)
if len(n.subscribers[key]) == 0 {
delete(n.subscribers, key)
}
break
}
}
}
return ch, unsubscribe
}
// notify closes all subscriber channels for key, waking any blocked PollComponent callers.
func (n *executionNotifier) notify(key chasm.ExecutionKey) {
n.mu.Lock()
subs := n.subscribers[key]
delete(n.subscribers, key)
n.mu.Unlock()
for _, ch := range subs {
close(ch)
}
}