Summary
Executors are registered and dispatched through a single global, name-keyed namespace with no workflow scoping. When two workflows contain different executor instances that happen to share a name, the second registration is silently dropped and that workflow's executor runs the first workflow's code.
Sharing one executor instance across multiple workflows is supported, works today, and must keep working. This issue is not about that case.
Same failure class as #50, but it needs no duplicate workflow names, so #66 does not address it. Spotted by Ahmed Muhsin (@ahmedmuhsin) while reviewing #66.
Repro
-
Build two workflows, each with its own executor instance under the same name:
var a = new WorkflowBuilder(new FunctionExecutor<string>("Step", HandlerA)).WithName("WorkflowA").Build();
var b = new WorkflowBuilder(new FunctionExecutor<string>("Step", HandlerB)).WithName("WorkflowB").Build();
-
Register both:
options.AddWorkflow(a);
options.AddWorkflow(b);
-
Run WorkflowB.
Expected: either WorkflowB's Step runs HandlerB, or the conflict is reported at registration.
Actual: WorkflowB's Step runs HandlerA. No error at registration or at runtime.
workflows=2 executors=1
'Step' boundToA=True boundToB=False
Contrast: the shared-instance case works
FunctionExecutor<string> shared = new("Step", Handler);
var a = new WorkflowBuilder(shared).WithName("WorkflowA").Build();
var b = new WorkflowBuilder(shared).WithName("WorkflowB").Build();
Also gives Executors.Count == 1, but here that is correct: binding.RawValue is reference-equal across both workflows, so collapsing to one registration preserves behavior. Core MAF places no ownership restriction on reusing an executor instance across workflows, unlike using a Workflow as a subworkflow of multiple parents.
RawValue reference equality is therefore what separates legitimate reuse from a real collision.
Root cause
Two places key on the bare executor name and keep the first writer:
-
ExecutorRegistry.Register — TryAdd drops later registrations of the same name without error:
this._executors.TryAdd(executorName, new ExecutorRegistration(executorId, binding));
-
ServiceCollectionExtensions.BuildWorkflowRegistrationRecursive — one HashSet<string> registeredActivities is threaded through every workflow, and activity names are derived as dafx-{executorName}, so dafx-Step is registered once globally against the first workflow's binding.
Dispatch is name-only, with no workflow context available to disambiguate:
string executorName = WorkflowNamingHelper.ToWorkflowName(activityFunctionName);
if (!durableOptions.Workflows.Executors.TryGetExecutor(executorName, out ExecutorRegistration? registration))
Impact
Silent and result-affecting rather than a startup failure — both workflows appear registered and runnable, and the problem surfaces only as wrong results at runtime. Names like Step, Start, Process, or Validate are natural choices and likely to collide across independently authored workflows.
Suggested fix
In ExecutorRegistry.Register, when executorName is already registered:
- if
ReferenceEquals(existing.Binding.RawValue, binding.RawValue), treat as reuse and no-op;
- otherwise throw, naming the executor and both workflows.
This mirrors what #66 did for duplicate workflow names, keeps the shared-instance scenario working, and leaves derived activity names untouched so in-flight orchestration histories stay valid.
Scoping keys by workflow (e.g. {workflowName}/{executorName}) was considered and rejected: it changes every derived activity name, breaking in-flight orchestrations, and registers a shared executor once per referencing workflow for no benefit.
Caveat to settle first: an executor supplied per workflow via a factory produces distinct instances that are logically the same executor, and a reference check would reject it. Same limitation #66 has for calling Build() twice. If that pattern needs support, this needs an identity concept beyond reference equality.
Acceptance criteria
- Reusing a single executor instance across multiple workflows continues to work, covered by a test.
- Two workflows containing different executor instances that share a name no longer silently cross-wire; the conflict is reported at registration time, naming the executor and the workflows involved.
- Derived activity names are unchanged, or any change is called out for backward compatibility with in-flight orchestrations.
Summary
Executors are registered and dispatched through a single global, name-keyed namespace with no workflow scoping. When two workflows contain different executor instances that happen to share a name, the second registration is silently dropped and that workflow's executor runs the first workflow's code.
Sharing one executor instance across multiple workflows is supported, works today, and must keep working. This issue is not about that case.
Same failure class as #50, but it needs no duplicate workflow names, so #66 does not address it. Spotted by Ahmed Muhsin (@ahmedmuhsin) while reviewing #66.
Repro
Build two workflows, each with its own executor instance under the same name:
Register both:
Run
WorkflowB.Expected: either
WorkflowB'sSteprunsHandlerB, or the conflict is reported at registration.Actual:
WorkflowB'sSteprunsHandlerA. No error at registration or at runtime.Contrast: the shared-instance case works
Also gives
Executors.Count == 1, but here that is correct:binding.RawValueis reference-equal across both workflows, so collapsing to one registration preserves behavior. Core MAF places no ownership restriction on reusing an executor instance across workflows, unlike using aWorkflowas a subworkflow of multiple parents.RawValuereference equality is therefore what separates legitimate reuse from a real collision.Root cause
Two places key on the bare executor name and keep the first writer:
ExecutorRegistry.Register—TryAdddrops later registrations of the same name without error:ServiceCollectionExtensions.BuildWorkflowRegistrationRecursive— oneHashSet<string> registeredActivitiesis threaded through every workflow, and activity names are derived asdafx-{executorName}, sodafx-Stepis registered once globally against the first workflow's binding.Dispatch is name-only, with no workflow context available to disambiguate:
Impact
Silent and result-affecting rather than a startup failure — both workflows appear registered and runnable, and the problem surfaces only as wrong results at runtime. Names like
Step,Start,Process, orValidateare natural choices and likely to collide across independently authored workflows.Suggested fix
In
ExecutorRegistry.Register, whenexecutorNameis already registered:ReferenceEquals(existing.Binding.RawValue, binding.RawValue), treat as reuse and no-op;This mirrors what #66 did for duplicate workflow names, keeps the shared-instance scenario working, and leaves derived activity names untouched so in-flight orchestration histories stay valid.
Scoping keys by workflow (e.g.
{workflowName}/{executorName}) was considered and rejected: it changes every derived activity name, breaking in-flight orchestrations, and registers a shared executor once per referencing workflow for no benefit.Caveat to settle first: an executor supplied per workflow via a factory produces distinct instances that are logically the same executor, and a reference check would reject it. Same limitation #66 has for calling
Build()twice. If that pattern needs support, this needs an identity concept beyond reference equality.Acceptance criteria