Skip to main content

Introducing Formengine - The New Formbuilder, try for FREE formengine.io.

Actions, IWorkflowActionProvider and CodeActions

To put it simply, Action is a method called when a process is being executed. Action allows you to call any code, business logic methods which your application already has, external services, side libraries methods, send mail, etc. Though extremely simple, Actions are a powerful tool of integration into an existing application.

There are two ways of creating Actions in your application: inherit the IWorkflowActionProvider interface and create Actions in this class, or create Actions right in the scheme in the CodeActions section. These two approaches may be combined.

Creation of Action in the scheme

It is quite easy to create Actions (or Conditions) in Designer. Open the CodeActions window by clicking a respective button in the toolbar. Then, create a new line, and specify the name and type - Action or Condition. Open the code editor by clicking Edit Code. Here you can write your code in C#; use the Compile button to check whether the code is compiled correctly. You may also edit the list of included namespaces in the Usings field. Usually, CodeActions are stored right in the scheme, and compiled once upon its first loading. However, if attribute IsGlobal is set, CodeAction will be saved in the WorkflowGlobalParameter table (or object), and it will be possible to utilize it in all schemes. Such CodeActions are compiled immediately upon executing of WorkflowRuntime. A simple CodeAction for retrieving the current date-time of execution in a process Activity can be as follows:

void CurrentDate(ProcessInstance processInstance, WorkflowRuntime runtime,
string parameter) {
processInstance.SetParameter<DateTime>("DateTimeNow", DateTime.Now,
ParameterPurpose.Persistence);
// parameters are not saved in Pre-Execution mode, but if you intentionally
// want to save a parameter in Pre-Execution mode, then use the following code
if (processInstance.IsPreExecution)
{
runtime.PersistenceProvider.SavePersistenceParameterAsync(processInstance,
"DateTimeNow").Wait();
}
}

Designer

If you want to create an asynchronous Action (or Condition) in the scheme, simply tick the checkbox Async beside its name in the CodeActions window in designer. Afterwards, you will be able to use the await keyword in the code. No further actions need to be undertaken in this case.

In order to enable the creation of Actions in designer, you should configure WorkflowRuntime in the following way (the configuration example already has this line):

runtime.EnableCodeActions();

If you are going to utilize methods from your assemblies in your code, you should notify WorkflowRuntime about them upon configuration:

runtime.RegisterAssemblyForCodeActions(Assembly.GetAssembly(typeof(SomeTypeFromMyAssembly)));

Besides, it is possible to set breakpoints in CodeActions for debugging purposes. To do this, you should first enable debugging upon initialization of WorkflowRuntime:

runtime.CodeActionsDebugOn();

Then, you can set a breakpoint right in the code of CodeAction by writing /*break*/ (commentary brackets do matter). It is rather convenient because if debugging is disabled, a breakpoint will transform into an ordinary comment. At the same time, if debugging is enabled, a breakpoint will turn into the following code:

if (System.Diagnostics.Debugger.IsAttached) {
System.Diagnostics.Debugger.Break();
}
Security

Please be aware that the code inside Code Actions is compiled dynamically and has full access to the environment in which the Workflow Engine is running. This means that allowing end users to create Code Actions can be dangerous, as it may lead to the execution of malicious code on the server. If you wish to provide such capabilities to end users, ensure that the Workflow Engine is running in an isolated environment. The safest option is to prohibit the creation of Code Actions and provide a set of pre-built Actions through a provider, as further described below.

IWorkflowActionProvider

Let's start with a code example, which you may copy and paste for a faster kick-off.

using OptimaJet.Workflow.Core.Model;
using OptimaJet.Workflow.Core.Runtime;

public class ActionProvider : IWorkflowActionProvider
{
private readonly Dictionary<string, Action<ProcessInstance, WorkflowRuntime, string>> _actions = new();

private readonly Dictionary<string, Func<ProcessInstance, WorkflowRuntime, string, CancellationToken, Task>>
_asyncActions = new();

private readonly Dictionary<string, Func<ProcessInstance, WorkflowRuntime, string, bool>> _conditions = new();

private readonly Dictionary<string, Func<ProcessInstance, WorkflowRuntime, string, CancellationToken, Task<bool>>>
_asyncConditions = new();

public ActionProvider()
{
// Register your actions in _actions and _asyncActions dictionaries
_actions.Add("MyAction", MyAction); // sync
_asyncActions.Add("MyActionAsync", MyActionAsync); // async

// Register your conditions in _conditions and _asyncConditions dictionaries
_conditions.Add("MyCondition", MyCondition); // sync
_asyncConditions.Add("MyConditionAsync", MyConditionAsync); // async
}

private void MyAction(ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter)
{
// Execute your synchronous code here
}

private async Task MyActionAsync(ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter, CancellationToken token)
{
// Execute your asynchronous code here. You can use await in your code.
}

private bool MyCondition(ProcessInstance processInstance, WorkflowRuntime runtime, string actionParameter)
{
// Execute your code here
return false;
}

private async Task<bool> MyConditionAsync(ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter, CancellationToken token)
{
// Execute your asynchronous code here. You can use await in your code.
return false;
}

#region Implementation of IWorkflowActionProvider

public void ExecuteAction(string name, ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter)
{
if (!_actions.ContainsKey(name))
{
throw new NotImplementedException($"Action with name {name} isn't implemented");
}

_actions[name].Invoke(processInstance, runtime, actionParameter);
}

public async Task ExecuteActionAsync(string name, ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter, CancellationToken token)
{
//token.ThrowIfCancellationRequested(); // You can use the transferred token at your discretion
if (!_asyncActions.ContainsKey(name))
{
throw new NotImplementedException($"Async Action with name {name} isn't implemented");
}

await _asyncActions[name].Invoke(processInstance, runtime, actionParameter, token);
}

public bool ExecuteCondition(string name, ProcessInstance processInstance, WorkflowRuntime runtime,
string actionParameter)
{
if (_conditions.ContainsKey(name))
{
return _conditions[name].Invoke(processInstance, runtime, actionParameter);
}

throw new NotImplementedException($"Condition with name {name} isn't implemented");
}

public async Task<bool> ExecuteConditionAsync(string name, ProcessInstance processInstance,
WorkflowRuntime runtime, string actionParameter, CancellationToken token)
{
//token.ThrowIfCancellationRequested(); // You can use the transferred token at your discretion
if (_asyncConditions.ContainsKey(name))
{
return await _asyncConditions[name].Invoke(processInstance, runtime, actionParameter, token);
}

throw new NotImplementedException($"Async Condition with name {name} isn't implemented");
}

public bool IsActionAsync(string name, string schemeCode)
{
return _asyncActions.ContainsKey(name);
}

public bool IsConditionAsync(string name, string schemeCode)
{
return _asyncActions.ContainsKey(name);
}

public List<string> GetActions(string schemeCode, NamesSearchType namesSearchType)
{
return _actions.Keys.Union(_asyncActions.Keys).ToList();
}

public List<string> GetConditions(string schemeCode, NamesSearchType namesSearchType)
{
return _conditions.Keys.Union(_asyncConditions.Keys).ToList();
}

#endregion
}

Let's see how it works. Workflow Engine identifies Actions and Conditions by unique names. In order to specify Actions in the Implementation section of an Activity, their names should be returned by the GetActions method. The same is true for Conditions in Transitions; the only difference is that you have to use the GetConditions method. When WorkflowRuntime calls an Action, it calls the ExecuteAction method and conveys the name of the Action to it from the scheme. Process object ProcessInstance, a link to WorkflowRuntime that called this Action, and Action Parameter from the scheme are also conveyed to this method. Then, you need to call your code in this method. This is also true for Conditions; the only difference is that the ExecuteCondition method with the Condition's name from the scheme is called; the called method should return true or false.

If you need to call asynchronous methods from Actions (and Conditions) you should add their handlers _asyncActions and _asyncConditions dictionaries, rather than _actions and _conditions. In this case the IsActionAsync and IsConditionAsync methods will return true and the engine will call the ExecuteActionAsync and ExecuteConditionAsync methods. You can use the keyword await in these methods and call asynchronous methods. If you are using asynchronous Actions, then you should use asynchronous versions of methods of the WorkflowRuntime object so that your application enjoys better performance and higher efficiency. For example, use ExecuteCommandAsync instead of ExecuteCommand and SetStateAsync instead of SetState. Pay attention to the fact, that when calling WorkflowInit.Runtime.ExecuteCommandAsync you can pass a CancellationToken object to it. Cancellation token will be passed to the ExecuteActionAsync and ExecuteConditionAsync methods without changes and its handling is solely up to you. You can use the token to cancel long-running operations or to set up timeouts.

In the above-mentioned example, the IWorkflowActionProvider implementation code won't change upon adding new Actions or Conditions, and this is good. The class contains four dictionaries that use the name of the Action (or Condition) as a key, and a delegate containing a link to your methods as a value. That's why upon adding a new Action (or Condition), you simply need to register this method in the dictionary under the name you would like to see in Designer. This code does not contain reflection or any other components that may slow things down.

Afterward, the provider's instance should be conveyed to WorkflowRuntime upon configuring in order to allow WorkflowRuntime learning about the provider you created.

runtime.WithActionProvider(new ActionProvider());

Once the new CodeAction is registered by IWorkflowActionProvider, it will be available to be used through the GUI interface in any Activity in the Implementation section, in both normal and pre-execution mode, just by clicking the list box 'Action', and then searching the CodeAction unique name that was set.

Designer

A combined approach

You may combine Actions created in IWorkflowProvider with those created in Designer. For example, you may set complex conditions in IWorkflowProvider, and then call and combine them in CodeActions. To do this, you should be able to call Actions from other Actions.

If you have a link to WorkflowRuntime, you can always get access to IWorkflowActionProvider:

var actionProvider = runtime.ActionProvider;

In order to call Actions created in the scheme, use the following method:

string parameter = string.Empty;
processInstance.ExecuteCodeAction("Action name", runtime, parameter);

In order to call Conditions created in the scheme, use the following method:

string parameter = string.Empty;
bool result = processInstance.ExecuteConditionFromCodeActions("Condition name", runtime, parameter);

Connection with the workflow designer

The list of Actions that you see in the workflow designer (in the Implementation section in an Activity) is formed as a combination of string lists returned by the IWorkflowActionProvider.GetActions method and received directly from the scheme. Elements with type Action are selected from the CodeActions section.

The list of Conditions (Actions that return a bool) that you see in the workflow designer (in the Condition section in a Transition) is formed as a combination of string lists returned by the IWorkflowActionProvider.GetConditions method and received directly from the scheme. Elements with type Condition are selected from the CodeActions section.

Priority of search for Action or Condition for execution

Situations where Action (or Condition) with the same name is simultaneously present in Global CodeActions, scheme CodeActions and in IWorkflowActionProvider occur rarely. However, sometimes a need may arise to make an override and replace the Global CodeAction of a scheme with a CodeAction from a scheme (or from IWorkflowActionProvider). The behaviour of WorkflowRuntime in this case is set with the ExecutionSearchOrder parameter. This parameter sets the Action search priority. By default, the following search algorithm is executed. Action (or Condition) with the specified name is searched for in CodeActions of the scheme, and, if found, it will be executed. If it is not found, then the search in Global CodeActions is performed. If it is not found there, then the search in IWorkflowActionProvider is performed. If it is not found in any of the three sources, then the NotImplementedException is thrown. Such a behaviour corresponds to the following setting:

runtime.SetExecutionSearchOrder(ExecutionSearchOrder.LocalGlobalProvider);

This is a default behaviour, and you should not specify it explicitly. You can change it by specifying one of the 6 possible priorities (LocalGlobalProvider, GlobalLocalProvider, LocalProviderGlobal, GlobalProviderLocal, ProviderLocalGlobal or ProviderGlobalLocal) which are set in the ExecutionSearchOrder enum.

runtime.SetExecutionSearchOrder(ExecutionSearchOrder order);

Memory usage

Compiling scripts in C# requires a lot of memory. The first time a process is created, the C# scripts are compiled, resulting in a momentary high memory spike. Memory consumption is scheme dependent. To reduce the effects of a memory spike use the WithExpressionsCompilerOptions method to configure ExpressionsCompiler to force the garbage collector to be invoked after each script compilation and disable parallel compilation.

runtime.WithExpressionsCompilerOptions(ExpressionsCompilerOptions.Forced)