fix(workflows): persist named WithVariable values across suspend/resume (#8166)

Named WithVariable(name, value) never set a storage driver, so values
were memory-only and vanished after bookmark resume. Default it to
workflow instance storage, matching the parameterless overload.

Fixes #8159

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Sipke Schoorstra 2026-09-15 00:58:15 +02:00 committed by GitHub
parent 2138f0997b
commit 81d630ea1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 135 additions and 1 deletions

View file

@ -93,6 +93,7 @@ public class WorkflowBuilder(IActivityVisitor activityVisitor, IIdentityGraphSer
{
var variable = new Variable<T>(name, value);
Variables.Add(variable);
variable.WithWorkflowStorage();
return variable;
}

View file

@ -116,7 +116,8 @@ public interface IWorkflowBuilder
Variable<T> WithVariable<T>();
/// <summary>
/// A fluent method for adding a variable to <see cref="Variables"/>.
/// A fluent method for adding a named variable to <see cref="Variables"/>.
/// The variable uses workflow instance storage by default so its value survives suspend and resume.
/// </summary>
Variable<T> WithVariable<T>(string name, T value);

View file

@ -0,0 +1,47 @@
using Elsa.Extensions;
using Elsa.Testing.Shared;
using Elsa.Workflows.Options;
using Microsoft.Extensions.DependencyInjection;
using Xunit.Abstractions;
namespace Elsa.Workflows.IntegrationTests.Scenarios.NamedVariablePersistence;
public class Tests
{
private readonly IWorkflowRunner _workflowRunner;
private readonly CapturingTextWriter _capturingTextWriter = new();
private readonly IWorkflowBuilderFactory _workflowBuilderFactory;
private readonly IServiceProvider _services;
public Tests(ITestOutputHelper testOutputHelper)
{
_services = new TestApplicationBuilder(testOutputHelper).WithCapturingTextWriter(_capturingTextWriter).Build();
_workflowBuilderFactory = _services.GetRequiredService<IWorkflowBuilderFactory>();
_workflowRunner = _services.GetRequiredService<IWorkflowRunner>();
}
[Fact(DisplayName = "Named WithVariable value survives suspend and resume")]
public async Task NamedWithVariable_ValueSurvivesSuspendAndResume()
{
// Arrange
await _services.PopulateRegistriesAsync();
var workflow = await _workflowBuilderFactory.CreateBuilder().BuildWorkflowAsync<NamedVariableSurvivesSuspendWorkflow>();
// Act
var started = await _workflowRunner.RunAsync(workflow);
var bookmark = started.WorkflowState.Bookmarks.Single(x => x.ActivityId == "Resume");
var runOptions = new RunWorkflowOptions { BookmarkId = bookmark.Id };
var resumed = await _workflowRunner.RunAsync(workflow, started.WorkflowState, runOptions);
// Assert
Assert.Equal(WorkflowStatus.Running, started.WorkflowState.Status);
Assert.Equal(WorkflowSubStatus.Suspended, started.WorkflowState.SubStatus);
Assert.Equal(WorkflowStatus.Finished, resumed.WorkflowState.Status);
Assert.Equal(
[
"before suspend: hello",
"after resume: hello"
],
_capturingTextWriter.Lines.ToList());
}
}

View file

@ -0,0 +1,23 @@
using Elsa.Workflows.Activities;
using Elsa.Workflows.Runtime.Activities;
namespace Elsa.Workflows.IntegrationTests.Scenarios.NamedVariablePersistence;
class NamedVariableSurvivesSuspendWorkflow : WorkflowBase
{
protected override void Build(IWorkflowBuilder builder)
{
var message = builder.WithVariable<string>("message", null!);
builder.Root = new Sequence
{
Activities =
{
new SetVariable<string>(message, "hello"),
new WriteLine(context => $"before suspend: {message.Get(context) ?? "<null>"}"),
new Event("Resume") { Id = "Resume" },
new WriteLine(context => $"after resume: {message.Get(context) ?? "<null>"}")
}
};
}
}

View file

@ -0,0 +1,62 @@
using Elsa.Workflows.Builders;
using Elsa.Workflows.Memory;
using NSubstitute;
namespace Elsa.Workflows.Core.UnitTests.Builders;
public class WorkflowBuilderTests
{
[Fact]
public void WithVariable_NameAndValue_UsesWorkflowInstanceStorage()
{
// Arrange
var builder = CreateBuilder();
// Act
var variable = builder.WithVariable("message", "hello");
// Assert
Assert.Equal("message", variable.Name);
Assert.Equal("hello", variable.Value);
Assert.Equal(typeof(WorkflowInstanceStorageDriver), variable.StorageDriverType);
Assert.Contains(variable, builder.Variables);
}
[Fact]
public void WithVariable_NameAndValue_UsesSameStorageAsParameterlessOverload()
{
// Arrange
var builder = CreateBuilder();
// Act
#pragma warning disable CS0618 // Parameterless overload is obsolete but remains the persistence baseline.
var unnamed = builder.WithVariable<string>();
#pragma warning restore CS0618
var named = builder.WithVariable("message", "hello");
// Assert
Assert.Equal(typeof(WorkflowInstanceStorageDriver), unnamed.StorageDriverType);
Assert.Equal(unnamed.StorageDriverType, named.StorageDriverType);
}
[Fact]
public void WithVariable_ExistingVariable_DoesNotOverrideStorageDriver()
{
// Arrange
var builder = CreateBuilder();
var variable = new Variable<string>("message", "hello");
// Act
builder.WithVariable(variable);
// Assert
Assert.Null(variable.StorageDriverType);
Assert.Contains(variable, builder.Variables);
}
private static WorkflowBuilder CreateBuilder() =>
new(
Substitute.For<IActivityVisitor>(),
Substitute.For<IIdentityGraphService>(),
Substitute.For<IActivityRegistry>());
}