0 How To: Use Forge in my Application
kiran jujjavarapu редактировал(а) эту страницу 2021-09-10 13:11:43 -07:00

This page gives in-depth details of how to integrate Forge into your application and utilize its features. It includes suggested use-cases with code samples, making it easy to copy/paste into your application and adapt it to your needs. It is recommended to start with the Forge QuickStart Guide to get a simple overview before jumping into this page. If you notice anything missing or incorrect, please use the "Issues" tab to bring it to attention, thank you!


TreeWalkerSession

TreeWalkerParameters


TreeWalkerSession

The TreeWalkerSession tries to walk the given ForgeTree schema to completion. It holds the logic for walking the ForgeTree schema, executing ForgeActions, and calling callbacks.

GetCurrentTreeNode

Description

Gets the current tree node being walked from the ForgeState. Inside of WalkTree, Forge will commit the CurrentTreeNode before the call to BeforeVisitNode.

Uses

  • If your service rehydrates or fails over to a secondary, it is useful to restart your active TreeWalkerSessions where they left off. After re-initializing your TreeWalkerSession with a persisted-backed IForgeDictionary, you can GetCurrentTreeNode to pass in to your WalkTree method. If the session is starting for the first time and no CurrentTreeNode exists, then you can fallback to using the ForgeTree.RootTreeNodeKey by default.

  • After completing a WalkTree, log the final visited TreeNode using GetCurrentTreeNode.

Code Samples

// Start walking at the persisted CurrentTreeNode if the session was previously active, otherwise use the defined starting node.
string currentTreeNode = await session.GetCurrentTreeNode() ?? forgeTrees[TreeWalkerSession.DefaultTreeName].RootTreeNodeKey;
result = await session.WalkTree(currentTreeNode);

WalkTree

Description

Walks the ForgeTree schema starting at the given TreeNodeKey. Returns the string Status of the session. Exceptions are thrown if tree walker hit a timeout, was cancelled, or failed.

Uses

  • After initializing your TreeWalkerSession, use this method to walk the tree. Wrap the call in a try/catch to catch any exceptions.
  • You can catch exceptions your application expects and handle it. For example, you could add code to BeforeVisitNode callback that checks if a rate limit allows the tree walker to visit the TreeNode or not. If rate limit is hit, throw a RateLimitHitException to cause the tree walker session to become Failed and cancel out. In your application, catch the RateLimitHitException, and schedule a retry at a later time. More details here.
  • The native ForgeAction, SubroutineAction, uses WalkTree after getting the initialized TreeWalkerSession from the application.

Code Samples

try
{
    await treeWalkerSession.WalkTree(currentTreeNode);
}
catch (RateLimitHitException e)
{}
// Initialize TreeWalkerSession for this subroutine.
TreeWalkerSession subroutineSession = this.parameters.InitializeSubroutineTree(input, intermediates.SessionId, this.parameters);

// Update KeyPrefix of ForgeState for state persistence separation.
subroutineSession.Parameters.ForgeState.UpdateKeyPrefix(subroutineSession.Parameters.RootSessionId, subroutineSession.Parameters.SessionId);

// Walk tree starting at CurrentTreeNode if persisted, otherwise RootTreeNodeKey if given, otherwise "Root" as default.
string currentTreeNode = await subroutineSession.GetCurrentTreeNode() ?? subroutineSession.Schema.RootTreeNodeKey;

// WalkTree may throw exceptions. We let this happen to allow for possible retry handling.
await subroutineSession.WalkTree(currentTreeNode);

Status

Description

The current status of the tree walker session.

  • Initialized - Set in the TreeWalkerSession constructor.
  • Running - Set at the start of WalkTree.
  • RanToComplete - WalkTree completed without exceptions.
  • CancelledBeforeExecution - Tree walker's CancellationToken was cancelled before calling WalkTree.
  • Cancelled - Tree walker was cancelled during WalkTree. This could be by CancellationToken getting cancelled, TreeWalkerSession.CancelWalkTree getting called, or by an OperationCanceledException getting thrown.
  • TimeoutOnAction - A TreeAction timeout was hit.
  • TimeoutOnNode - A TreeNode timeout was hit.
  • RanToCompletion_NoChildMatched - The last TreeNode visited had ChildSelectors but did not find any matches. No exception is thrown in this case, but a special Status is returned to signal this event occurred.
  • Failed_EvaluateDynamicProperty - Exception was thrown while Forge was evaluating a dynamic property from the ForgeTree schema.
  • Failed - Unhandled exception was thrown during WalkTree.

Uses

  • Grab the Status from your session.WalkTree and log it. If there is an exception during WalkTree, you can still grab the session.Status since tree walker updates the Status before throwing.

Code Samples

try
{
    await treeWalkerSession.WalkTree(currentTreeNode);
}
finally
{
    // On exception, TreeWalkerSession updates its Status property but does not return it since it throws.
    // Let's grab the Status if this happened.
    result = treeWalkerSession.Status;
}

CancelWalkTree

Description

Cancels the WalkTree cancellation token source, which is a linked token source from the TreeWalkerParameters.Token. Used to send cancellation signal to action tasks and to stop tree walker from visiting future nodes. CancelWalkTree is called at the end of every WalkTree to ensure all Actions/Tasks see the triggered cancellation token.

Uses

  • CancelWalkTree could be exposed on a DebugAPI surface from your application in case something unexpected happens and you need to manually cancel a running tree walker session.

VisitNode

Description

Visits a TreeNode in the ForgeTree, performing type-specific behavior as necessary. Returns the next child to visit. This method is called by WalkTree between BeforeVisitNode and AfterVisitNode.

Uses

  • VisitNode is exposed publicly, but should not be used in the typical WalkTree scenario since it bypasses calls to CommitCurrentTreeNode, BeforeVisitNode, and AfterVisitNode.
  • Calling VisitNode instead of WalkTree can be an option in some scenarios. I've seen teams use Forge as a state machine framework where each TreeNode represents a state in the state machine. In this scenario, you don't want to WalkTree, but instead call VisitNode individually.

EvaluateDynamicProperty

Description

While walking the tree, Forge will use EvaluateDynamicProperty to retrieve values from the ForgeTree schema. The method iterates through the given schema property, evaluating any Roslyn expressions found in the values. When a knownType is given, Forge will instantiate that type instead of a dynamic object.

Uses

  • EvaluateDynamicProperty is exposed publicly for testing purposes. Your application could write UnitTests over your ForgeTree schema files to ensure every property can be evaluated successfully. See code sample below.

Code Samples

TreeWalkerSession.GetActionsMapFromAssembly(typeof(TardigradeAction).Assembly, out this.actionsMap);
foreach (ForgeTree forgeTree in this.forgeTrees.Values)
{
    foreach (KeyValuePair<string, TreeNode> treeNode in forgeTree.Tree)
    {
        // Validates Properties for tree node.
        nodeKey = treeNode.Key;
        dynamicExpression = treeNode.Value.Properties;
        await tw.EvaluateDynamicProperty(dynamicExpression, null);

        if (treeNode.Value.Actions != null)
        {
            // Validates dynamic expression for action node.
            dynamicExpression = treeNode.Value.Timeout;
            await tw.EvaluateDynamicProperty(dynamicExpression ?? -1, typeof(int));

            foreach (KeyValuePair<string, TreeAction> treeAction in treeNode.Value.Actions)
            {
                // Validates action defined on schema exists in ActionMap or is Forge's native LeafNodeSummaryAction.
                if (!this.actionsMap.ContainsKey(treeAction.Value.Action) && !treeAction.Value.Action.Equals(TreeWalkerSession.LeafNodeSummaryAction))
                {
                    throw new Exception(string.Format("action: {0} is undefined", treeAction.Value.Action));
                }

                dynamicExpression = treeAction.Value.Input;
                Console.WriteLine("TreeNode: {0} Action: {1}\n", treeNode.Key, treeAction.Key);

                Type inputType = this.actionsMap.TryGetValue(treeAction.Value.Action, out ActionDefinition actionDefinition) ? actionDefinition.InputType : typeof(ActionResponse);
                var result = await tw.EvaluateDynamicProperty(dynamicExpression, inputType);
                this.PrintDynamic(result);
                Console.WriteLine();

                dynamicExpression = treeAction.Value.Properties;
                result = await tw.EvaluateDynamicProperty(dynamicExpression, null);
                this.PrintDynamic(result);
                Console.WriteLine();

                dynamicExpression = treeAction.Value.Timeout;
                await tw.EvaluateDynamicProperty(dynamicExpression ?? -1, typeof(int));

                // Note: Setting LastTreeActionSuffix after each Action helps to set up calls to GetLastActionResponseAsync().
                await forgeDictionary.Set<string>(TreeWalkerSession.LastTreeActionSuffix, treeAction.Key);
            }
        }

        if (treeNode.Value.ChildSelector != null)
        {
            // Validates ShouldSelect for tree node.
            foreach (ChildSelector child in treeNode.Value.ChildSelector)
            {
                dynamicExpression = child.ShouldSelect;
                if (dynamicExpression != null)
                {
                    await tw.EvaluateDynamicProperty(dynamicExpression, typeof(bool));
                }
            }
        }
    }
}

GetActionsMapFromAssembly

Description

Initializes the actionsMap from the given assembly. This map is generated using reflection to find all the classes with the applied ForgeActionAttribute from the given Assembly. Native ForgeActions are also added to the actionsMap, including SubroutineAction.

Called in TreeWalkerSession constructor using the TreeWalkerParameters.ForgeActionsAssembly.

Uses

  • Used in UnitTests to confirm ActionNames given on the ForgeTree schema map to valid ForgeActions.

Code Samples

See EvaluateDynamicProperty Code Samples

GetLastTreeAction

Description

Gets the last committed TreeActionKey from the ForgeState. This is committed each time a new ActionResponse gets committed. Forge calls this inside GetLastActionResponse.

GetLastActionResponse GetLastActionResponseAsync

Description

Gets the last executed TreeAction's ActionResponse data from the ForgeState.

GetOutput GetOutputAsync

Gets the ActionResponse data from the ForgeState for the given TreeActionKey.

public static strings

  • ActionResponseSuffix = "AR"; - The ActionResponse suffix appended to the end of the key in forgeState that maps to an ActionResponse. Key: _AR
  • CurrentTreeNodeSuffix = "CTN"; - The CurrentTreeNode suffix appended to the end of the key in forgeState that maps to the current TreeNode being walked. Key: _CTN
  • LastTreeActionSuffix = "LTA"; - The LastTreeAction suffix appended to the end of the key in forgeState that maps to the last TreeAction that was committed. Key: _LTA
  • IntermediatesSuffix = "Int"; - The Intermediates suffix appended to the end of the key in forgeState that maps to an ActionContext's GetIntermediates object. Key: _Int
  • TreeInputSuffix = "TI"; - The TreeInput suffix appended to the end of the key in forgeState that maps to this tree walking session's TreeInput object. Key: _TI
  • PreviousActionResponseSuffix = "PAR"; - The PreviousActionResponse suffix appended to the end of the key in forgeState that maps to a previously persisted ActionResponse. When a TreeNodeKey in a tree walking session was previously successfully visited, the ActionResponses get wiped and persisted to PreviousActionResponse. GetPreviousActionResponse method is available in ActionContext. Key: _PAR
  • LeafNodeSummaryAction = "LeafNodeSummaryAction"; - The name of the native LeafNodeSummaryAction.
  • DefaultTreeName = "RootTree"; - The default TreeName if not specified in the TreeWalkerParameters.

TreeWalkerParameters

The TreeWalkerParameters class contains the required and optional properties used to instantiate a TreeWalkerSession.

Guid SessionId

Description

The unique identifier for a tree walking session. Forge creates a unique SessionId inside of SubroutineAction to walk Subroutine sessions. RootSessionId gets set to SessionId if not specified.

Uses

  • Recommended to use in IForgeDictionary ForgeState for state separation between sessions. RootSessionId and SessionId are used to prefix all keys in IForgeDictionary. This allows a single dictionary to be used for all tree walker sessions with the safety that sessions cannot interact with each others rows.
  • Can be same value as RootSessionId. Just note that any Subroutines executed from the root tree will have different SessionIds.

string JsonSchema

Description

The serialized ForgeTree JSON schema. Forge TreeWalkerSession constructor deserializes this into a ForgeTree.

Note that TreeWalkerSession only knows about a single ForgeTree. If your application uses multiple ForgeTrees or JSON files, you should only pass the serialized "Root" ForgeTree here. Subroutines are handled in InitializeSubroutineTree, where the Subroutine TreeWalkerSession will be initialized with the corresponding Subroutine ForgeTree.

IForgeDictionary ForgeState

Description

Stores information that is relevant to TreeWalker while walking the tree, such as CurrentTreeNode, ActionResponses, and more.

More details here:

Uses

  • You can use the same IForgeDictionary backing store across multiple TreeWalkerSessions. This is possible because ForgeDictionary prefixes the RootSessionId and SessionId to all keys when getting/setting values. This limits the scope of each Session to only that Session's state. This allows many Sessions to save state to a single dictionary, since their keys will not collide.
  • ForgeDictionary implements IForgeDictionary, uses a local Dictionary as its backing store, and can be used in applications that do not require persisted state. This may be good enough for your application if you are okay to restart the whole tree walking session in case of rehydration/failover of your app.
  • Used indirectly from ForgeTree schema through ITreeSession to get ActionResponses. Ex) "C#|Session.GetLastActionResponse().Status == "Success""
  • When implementing your own IForgeDictionary, it is important to use similar logic as ForgeDictionary with regards to using a KeyPrefix. This is typically RootSessionId_SessionId_. The KeyPrefix should always precede the key when using the forgeStateTable to limit the scope to the current SessionId.

ITreeWalkerCallbacks Callbacks

Description

Forge tree walker calls these callback methods while walking the tree.

BeforeVisitNode is called before visiting each node, offering a convenient global hook into all TreeNodes.

AfterVisitNode is called after visiting each node, even if there is an exception thrown in VisitNode.

More details here:

Uses

  • Before and AfterVisitNode are great places to add logging. Add logging to these callbacks, ForgeActions, and Before/After WalkTree in your application to compile a complete diagnostic view of your tree walking session.
  • Extend new features by utilizing the dynamic TreeNode.Properties. Trigger desired behavior from the ForgeTree schema for specific TreeNodes by populating TreeNode.Properties with a property that your callbacks know how to read.
  • Break out of the tree walking session by throwing an exception. Perhaps because of a safety check or rate limit hit. Retry WalkTree at a later time.

Code Samples

public async Task BeforeVisitNode(
    Guid sessionId,
    string treeNodeKey,
    dynamic properties,
    dynamic userContext,
    string treeName,
    Guid requestIdentifier,
    CancellationToken token)
{
    Logger.LogForgeEvents(
        requestIdentifier: requestIdentifier.ToString(),
        sessionId: sessionId.ToString(),
        treeName: treeName,
        resourceId: resourceId,
        resourceType: resourceType,
        messageTrigger: "OnBeforeVisitNode",
        treeNodeKey: treeNodeKey,
        treeActionName: string.Empty,
        treeActionInput: string.Empty,
        properties: JsonConvert.SerializeObject(properties),
        taskStatus: string.Empty,
        message: messageToLog);
}
IDictionary<string, RateLimitObject> rateLimitsDic = null;
if (properties != null)
{
    IDictionary<string, dynamic> propertiesDic = properties.ToObject<IDictionary<string, dynamic>>();
    if (propertiesDic.ContainsKey(RateLimitObject.RateLimits))
    {
        rateLimitsDic = propertiesDic[RateLimitObject.RateLimits].ToObject<IDictionary<string, RateLimitObject>>();
    }
}

CancellationToken Token

Description

The CancellationToken, upon cancellation request, will immediately halt the tree walking session. Any ForgeActions in progress will also see the cancellation request, though Forge tree walker will likely cancel out before receiving a response from any ForgeAction.

CancelWalkTree can also be used to the same effect. Though this method cancels a linked token source created from the CancellationToken. This leaves the CancellationToken intact in case it is used for other purposes.

Uses

  • It is recommended to give your application's CancellationToken here so that all TreeWalkerSessions can get cleaned up automatically upon replica failover.

object UserContext

Description

The UserContext is an object defined in your application that can be referenced when evaluating ForgeTree schema expressions and executing ForgeActions. This simple object plays a key role in making Forge highly extensible by connecting all the major contribution authors: application, ForgeTree, ForgeAction.

Uses

  • Initialize your UserContext with session-specific information.
    • E.g. Let's say you are using Forge to recover from a faulted state that is triggered by getting a FaultedRequest. That FaultedRequest containing the fault information can be added as a property of UserContext. You can then use it in the ForgeTree to help choose the appropriate ForgeActions to execute.
  • Expose your application's data models in the UserContext. This allows you to dynamically access data in real time as you walk the tree sessions. Any data that could be accessible in a "normal" application can also be accessible in Forge.
  • Expose your application's microservices/relevant classes in the UserContext. This broadens the scope of your Forge sessions to the various classes inside your application.
  • Expose service clients with which your application interacts. This further broadens the scope of your Forge sessions, allowing them to make API calls to outside services.
  • Create helper methods to simplify long or complex patterns in ForgeTree schema. However, keep in mind that you want to keep the versatility on the ForgeTree. If you write logic in UserContext that could change often, it will require an app deployment each time versus a config/data deployment if you only need to update the ForgeTree.
  • Set up interfaces to limit the scope of access for specific authors.
    • E.g. Perhaps you do not want your ForgeAction authors to have full access to everything inside your UserContext. You can create an interface and only expose that interface to the ForgeAction authors. See code sample below.

Code Samples

  • Initialize UserContext with data and interfaces necessary for successfully walking the tree.
ForgeUserContext userContext = new ForgeUserContext(
    faultRequest, // Session-specific information about a fault request.
    this.kafkaCallbacks, // Hook to application's data model through Kafka.
    this.repairManager, // Microservice inside my application that manages fault requests.
    this.rateLimitManager, // Microservice inside my application that handles rate limiting.
    this.cancellationToken); // Application's cancellation token.
  • Organize your data model in a way to intuitively retrieve the data.
{
    "ShouldSelect": "C#|!UserContext.Container.Exists"
}
private Lazy<ContainerObject> containerObject;
public IContainerObject Container => this.containerObject.Value;

public ForgeUserContext(...)
{
    // ...
    this.containerObject = new Lazy<ContainerObject>(this.GetContainerObject);
}

private ContainerObject GetContainerObject()
{
    ResourceType resourceType = this.FaultRequest.ResourceType;
    Guid resourceId = this.FaultRequest.ResourceId;

    if (resourceType == ResourceType.Container && resourceId != Guid.Empty)
    {
        return new ContainerObject(resourceId, this.kafkaCallbacks, this.FaultRequest, this.cancellationToken);
    }

    return null;
}
  • Create helper methods to simplify long or complex patterns in ForgeTree schema.
{
    // Bad - In order to cast the Output, the amount of parenthesis make it difficult to read.
    "ShouldSelect": "C#|((AbTestOutput)((await Session.GetOutputAsync(\"Run_AbTestAction\")).Output)).ChosenAction == \"NoOp\""

    // Good - Creating a helper method in UserContext makes the line more human-readable.
    "ShouldSelect": "C#|UserContext.GetActionResponseOutput<AbTestOutput>(\"Run_AbTestAction\").ChosenAction == \"NoOp\""
}
public T GetActionResponseOutput<T>(string treeActionKey)
{
    ActionResponse actionResponse = this.GetActionResponse(treeActionKey);

    if (actionResponse != null && actionResponse.Status == Status.Success.ToString())
    {
        return this.Cast<T>(actionResponse.Output);
    }

    return default;
}

public T Cast<T>(object obj)
{
    if (obj is JObject)
    {
        return ((JObject)obj).ToObject<T>();
    }

    if (obj is T)
    {
        return (T)obj;
    }

    string s = JsonConvert.SerializeObject(obj);
    return JsonConvert.DeserializeObject<T>(s);
}
  • Set up interfaces to limit the scope of access for specific authors, such as the ForgeAction authors.
public interface IForgeUserContext
{
    // Expose the IFCL client interface.
    IFCL FCL { get; }
}

// Implement the interface.
public class ForgeUserContext : IForgeUserContext {}

// Add an inheritance layer to only expose the IForgeUserContext to the ForgeAction authors.
public abstract class BaseCommonAction : BaseAction
{
    public IForgeUserContext UserContext { get; private set; }

    public override Task<ActionResponse> RunAction(ActionContext actionContext)
    {
        // The passed in UserContext into the TreeWalkerSession should implement the IForgeUserContext interface.
        this.UserContext = (IForgeUserContext)actionContext.UserContext;
        // ...

        return this.RunAction();
    }

    public abstract Task<ActionResponse> RunAction();
}

// Use the IForgeUserContext interface in your ForgeActions.
[ForgeAction]
public class TardigradeAction: BaseCommonAction
{
    public override async Task<ActionResponse> RunAction()
    {
        await this.UserContext.FCL.ExecuteTardigrade();
    }
}

Assembly ForgeActionsAssembly

Description

The Assembly containing ForgeActionAttribute tagged classes. From the Assembly, Forge uses reflection to create an ActionsMap which maps ActionNames to class Types that are tagged with the ForgeActionAttribute. These tagged classes make up the ForgeActions available to be called from the ForgeTree schema.

More details here:

Uses

  • Create ForgeAction classes by tagging them with the ForgeActionAttribute. All your ForgeActions should live in the same Assembly/csproj so Forge can find them all from the passed in Assembly.
  • Get the Assembly from one of your ForgeActions. See code sample below.

Code Samples

TreeWalkerParameters parameters = new TreeWalkerParameters(
    sessionId,
    rootSchema,
    forgeState,
    this.forgeWrapperCallbacks,
    this.cancellationToken)
{
    // ...
    ForgeActionsAssembly = typeof(TardigradeAction).Assembly
};

ScriptCache

Description

The Script cache used by Forge's internal ExpressionExecutor to cache and re-use compiled Roslyn scripts. Using this is highly recommended to speed up Forge tree walker if your application kicks off multiple tree walker sessions in its lifetime.

There is an upfront cost when executing each unique Roslyn script from the ForgeTree schema ("C#|..."). You must pay this cost each time your application boots up fresh (after a failover, rehydration, replica leader election, etc..). Each expression gets dynamically compiled at runtime, which can take roughly 500-2000ms. After compiling the Script once, it gets executed quickly on subsequent calls. So when multiple tree walker sessions hit the same Roslyn expression, the first session would pay the cost of compiling the Script, and all future sessions would execute the expression quickly.

Uses

  • Simply initialize a single ScriptCache in your application. Pass the same ScriptCache dictionary to every tree walker session.
  • Warm up or prime the ScriptCache in the background when your application starts to avoid the cost while walking tree sessions.

Code Samples

private ConcurrentDictionary<string, Script<object>> scriptCache = new ConcurrentDictionary<string, Script<object>>();

TreeWalkerParameters parameters = new TreeWalkerParameters(
    sessionId,
    rootSchema,
    forgeState,
    this.forgeWrapperCallbacks,
    this.cancellationToken)
{
    // ...
    ScriptCache = this.scriptCache
};

Dependencies

Description

List of dependencies required to evaluate Roslyn expressions found in the ForgeTree schema.

Forge's internal ExpressionExecutor class that evaluates Roslyn expressions has a limited amount of references by default (System and System.Threading.Tasks). Any other Types that are used directly in a Roslyn expression must be added to this Dependencies list for the Roslyn script to compile and execute successfully.

Note that you can use object Types indirectly without adding them to the Dependencies list. For example, "C#|Session.GetLastActionResponse.Output" uses an ActionResponse Type without ActionResponse being added as a Dependency.

Note that the entire Namespace of each Type in Dependencies get added as references. So be mindful of the scope of each Type's Namespace.

Uses

  • Add a Type Dependency when you need to use the type directly in a Roslyn expression. Examples include: referencing an enum, casting a specific type.

Code Samples

public enum Status
{
    Success = 0,
    Failure = 1,
    Timeout = 2,
    Pending = 3
}

TreeWalkerParameters parameters = new TreeWalkerParameters(
    sessionId,
    rootSchema,
    forgeState,
    this.forgeWrapperCallbacks,
    this.cancellationToken)
{
    // ...
    Dependencies = new List<Type>() { typeof(Status), typeof(AbTestOutput) }
};
{
    "ShouldSelect": "C#|(await Session.GetLastActionResponseAsync()).Status == Status.Success.ToString()"
}
{
    "ShouldSelect": "C#|UserContext.GetActionResponseOutput<AbTestOutput>(\"Run_AbTestAction\").ChosenAction == \"NoOp\""
}

ExternalExecutors

Description

External executors work similarly to the built-in Roslyn evaluator, but use their own string match and evaluation logic on ForgeTree schema expressions.

The key is the string that Forge will attempt to match string schema properties against. Similar to "C#|" for Roslyn expressions, Forge will check if string schema properties StartsWith any of the ExternalExecutor keys.

The value is the Func that Forge calls when the string key matches. This Func<string, CancellationToken, Task