Skip to content

How To: Author a ForgeAction

Travis Jensen edited this page Jun 4, 2020 · 3 revisions

This page provides a detailed guide how to author a ForgeAction. Ample space is given for code examples that make it easy to copy/paste 90% of what you need. We then dive deeper into how the architecture works and what tools are available to you. If you notice anything missing or incorrect, please use the "Issues" tab to bring it to attention, thank you!


Create your FooAction class skeleton

BaseAction Inheritance

Crafting your FooActions RunAction method

Forge Behavior and how it changes based on RunAction response and ForgeSchema configuration


Create your FooAction class skeleton

namespace Foo.ForgeActions
{
    using System;
    using System.Threading;
    using System.Threading.Tasks;

    using Forge.Attributes;
    using Forge.TreeWalker;

    [ForgeAction(InputType : typeof(FooInput))]
    public class FooAction : BaseCommonAction
    {
        public override async Task<ActionResponse> RunAction()
        {
            // do something.
        }
    }

    public class FooInput
    {
        public string Command { get; set; }
    }
}

Explaining the components so far..

ForgeActionAttribute:

[ForgeAction(InputType : typeof(FooInput))]

Forge uses reflection to find all classes that are tagged with the ForgeActionAttribute inside the ForgeActionsAssembly passed in TreeWalkerParameters. The class name is used in the TreeAction.Action property in the ForgeSchema.

This Attribute contains a single optional parameter, the InputType. InputType should be included if the Action wishes for Forge to instantiate that object from the ForgeSchema and pass it to the Action.

FooAction class:

[ForgeAction(InputType : typeof(FooInput))]
public class FooAction : BaseCommonAction

Forge defines a native BaseAction abstract class that all Actions must inherit from. You can have your ForgeActions implement the BaseAction directly if you like. In this example we have an additional encapsulation layer in the BaseCommonAction. We'll go into more detail about the inheritance structure later.

RunAction method:

public override async Task<ActionResponse> RunAction()

Your Action logic lives here. This method is called by BaseCommonAction, which is called by Forge while walking the tree.

FooInput class:

public class FooInput

Your FooInput class defines properties that you wish to dynamically instantiate from the ForgeSchema and have available in your FooAction. For example:

  • CollectDiagnosticsInput defines a string Command property.
  • The CollectDiagnosticsAction uses this Command to execute an API.
  • This allows Schema authors to use the CollectDiagnosticsAction to run any arbitrary Command they wish by utilizing the same Action with different Inputs.

You do not need to define an Input class if your Action does not require one. You can also use any existing class as your InputType, but it may be easier to iterate on a locally created class like the example.

BaseAction Inheritance

You may wish to not expose the entire ActionContext to your ForgeActions (perhaps different developers/teams could be updating Actions, and you want to limit their scope).

Let's check out this BaseCommonAction class (that was mostly copied from the unit tests) as an example:

        public abstract class BaseCommonAction : BaseAction
        {
            public object Input { get; private set; }

            public CancellationToken Token { get; private set; }

            public Guid SessionId { get; private set; }

            public string TreeNodeKey { get; private set; }

            public ICommonObject Common { get; private set; }

            private ActionContext actionContext;

            public override Task<ActionResponse> RunAction(ActionContext actionContext)
            {
                this.Input = actionContext.ActionInput;
                this.Token = actionContext.Token;
                this.SessionId = actionContext.SessionId;
                this.TreeNodeKey = actionContext.TreeNodeKey;
                this.Common = actionContext.UserContext.GetCommonObject(actionContext);
                this.actionContext = actionContext;

                return this.RunAction();
            }

            public abstract Task<ActionResponse> RunAction();
            ...
        }

How Forge calls Actions

  • Forge picks up Action names from the passed in ForgeSchema.
  • Forge uses reflection to grab your class that is tagged with the ForgeActionAttribute.
    • The class name must match the Action name from the ForgeSchema.
    • The class must inherit from Forge's BaseAction abstract class.
    • The class must be defined in the passed in Assembly holding all ForgeActionAttribute tagged classes.
  • Forge then instantiates your ForgeAction class (and Input object) and calls the RunAction(ActionContext) method.

BaseAction inheritance

In the example application, we have a hierarchy of BaseAction classes that do common setup for the Actions. These are the BaseCommonAction, BaseNodeAction, and BaseContainerAction classes. Node and Container inherit from Common, and Common inherits from Forge's BaseAction class. In practice, the call chain looks like this: TreeWalker -> BaseNodeAction.RunAction -> BaseCommonAction.RunAction -> FooForgeAction.RunAction. These Base*Action classes make available common properties/methods/interfaces to ForgeActions, without exposing the entire ActionContext. Examples of what you can do inside your ForgeAction classes: this.Input, this.Token, this.Common, this.Node, this.Container.

Building additional inheritance layers on BaseAction helps to aggregate common setup for ForgeActions, and control the encapsulation of interfaces exposed. This in turn makes the ForgeAction authoring experience cleaner and less error-prone.

Crafting your FooActions RunAction method

Standard RunAction template

Most of the existing example ForgeActions look very similar. That is because they follow the same basic steps: Setup, Execution, Logging, Returning, Exception Handling. When writing your own FooAction, it is recommended to follow the same pattern unless additional steps are required. This helps keep up the code readability and maintainability.

Let's look at an example CollectDiagnosticsAction and then break down each step. Note that some additional app-side features are included in the example.

        public override async Task<ActionResponse> RunAction()
        {
            try
            {
                CollectDiagnosticsInput actionInput = (CollectDiagnosticsInput)this.Input;
                Guid nodeId = this.Node.NodeId;

                ActionResponse actionResponse = new ActionResponse() { Status = Status.Success.ToString() };

                // Call API to perform action.
                this.Common.TraceMessage(string.Format(
                    "Executing ForgeAction: {0} on NodeId: {1}.",
                    ActionName,
                    nodeId));

                bool result = false;
                await RetryHelper.ExecuteApiWithRetries(
                    async () =>
                    {
                        result = await this.HttpClient.CollectDiagnosticsAsync(nodeId, this.Common.Token);
                    },
                    (string ex) => this.Common.LogError(ex),
                    this.Common.Token);

                // Log and return result.
                if (!result)
                {
                    throw new Exception("Throw exception to retry, CollectDiagnosticsAction returned false.");
                }

                string msg = string.Format(
                    "Executed ForgeAction: {0}. NodeId: {1}. Result: {2}.",
                    ActionName,
                    nodeId,
                    result);
                this.Common.TraceMessage(msg);
                this.Common.LogMessage(msg, actionResponse.Status);

                return actionResponse;
            }
            catch (Exception e)
            {
                this.Common.GenericExceptionHandler(nodeId.ToString(), ActionName, e);

                // Throwing the base exception will throw OperationCanceledException instead of AggregateException in cancel condition.
                throw e.GetBaseException();
            }
        }

Setup Step:

        public override async Task<ActionResponse> RunAction()
        {
            try
            {
                CollectDiagnosticsInput actionInput = (CollectDiagnosticsInput)this.Input;
                Guid nodeId = this.Node.NodeId;

                ActionResponse actionResponse = new ActionResponse() { Status = Status.Success.ToString() };
                ...

this.Input

Forge uses reflection to instantiate your FooInput object from the properties in the ForgeSchema. This object is passed as an object type, so it must be cast to your FooInput type before using it.

Execution Step:

                await RetryHelper.ExecuteApiWithRetries(
                    async () =>
                    {
                        result = await this.HttpClient.CollectDiagnosticsAsync(nodeId, this.Common.Token);
                    },
                    (string ex) => this.Common.LogError(ex),
                    this.Common.Token);

RetryHelper.ExecuteApiWithRetries

The RetryHelper is a static helper class that wraps the async Func around exception handling and retry logic. It's recommended to build something like this if the endpoint of your API calls could have intermittent failures.

this.HttpClient

In this example, the HttpClient represents the client performing API calls.

Logging Step:

                this.Common.TraceMessage(string.Format(
                    "Executing ForgeAction: {0} on NodeId: {1}.",
                    ActionName,
                    nodeId));
                ...
                string msg = string.Format(
                    "Executed ForgeAction: {0}. NodeId: {1}. Result: {2}.",
                    ActionName,
                    nodeId,
                    result);
                this.Common.TraceMessage(msg);
                this.Common.LogMessage(msg, actionResponse.Status);

this.Common.TraceMessage / LogMessage / LogError

In this example, three methods exist for logging, depending on the scenario. this.Common.TraceMessage - Log more verbose/frequent messages here. this.Common.LogMessage - Log important messages here, typically once per Action with the result. this.Common.LogError - Log error messages here.

Return Step:

                ActionResponse actionResponse = new ActionResponse() { Status = Status.Success.ToString() };
                ...
                return actionResponse;

ActionResponse

ActionResponse objects contain string Status, int StatusCode, and object Output properties. An ActionResponse must be returned, so go ahead and instantiate one. A Status enum is used here when filling in the Status and StatusCode properties for consistency.

Persistence

The returned ActionResponse object is persisted in IForgeDictionary and can be read from the ForgeSchema. It is common practice in ForgeSchema to execute an Action, and then call Session.GetLastActionResponse in the ChildSelector. This allows authors to read the values and decide the next path at runtime.

For example:

            "ChildSelector": [
                {
                    "ShouldSelect": "C#|(await Session.GetLastActionResponseAsync()).Status == Status.Success.ToString()",
                    "Child": "SuccessNode"
                }
            ]

Exception Handling Step:

            try
            {
                ...
                // Log and return result.
                if (!result)
                {
                    throw new Exception("Throw exception to retry, CollectDiagnosticsAction returned false.");
                }
                ...
            }
            catch (Exception e)
            {
                this.Common.GenericExceptionHandler(nodeId.ToString(), ActionName, e);

                // Throwing the base exception will throw OperationCanceledException instead of AggregateException in cancel condition.
                throw e.GetBaseException();
            }

try/catch

It is important to not throw any unexpected exceptions in your FooAction, so wrap everything in a try/catch. I'll explain Forge exception handling behavior in its own section lower down.

GenericExceptionHandler

In this example, GenericExceptionHandler method is always used if you catch an exception. It handles some funky logic for handling TaskCanceledException depending if it came from HttpClient or from cancellation token. I recommend using this logic, so I'll include the method below.

Throwing e.GetBaseException() is important in case the exception is an OperationCanceledException, which will be explained below.

        public void GenericExceptionHandler(string resourceId, string name, Exception e)
        {
            // Log exception and rethrow.
            string message = string.Format(
                "ForgeAction: {0} with ResourceId: {1} failed with exception: {2}",
                name,
                resourceId,
                e.ToString());

            this.Common.TraceMessage(message);

            ForgeUserContext userContext = (ForgeUserContext)this.actionContext.UserContext;

            if (e.HasTaskCanceledException())
            {
                this.Common.LogMessage(msg, "Cancelled");

                // TaskCancelledException can be thrown when cancellation token is requested, or when HttpClient hits a timeout.
                // Let's throw if token is cancelled to stop this action, or in the case of HttpClient timeout, throw a
                // new exception to trigger retry.
                actionContext.Token.ThrowIfCancellationRequested();
                throw new Exception(e.ToString());
            }

            this.Common.LogMessage(msg, "Faulted");
        }

Forge Behavior and how it changes based on RunAction response and ForgeSchema configuration

(This part is a bit dense, but contains vital information, please read!)

Forge has different behaviors depending what Timeout, RetryPolicy, and flags are set in the ForgeSchema. Forge also behaves differently for different exceptions. It's important to understand these behaviors so your FooAction will execute as you intend with no unintended bugs.

Returning ActionResponse

If an ActionResponse is returned (i.e. no exceptions were thrown), Forge will persist the object and continue walking the tree. It does not matter what the contents of the ActionResponse are, Forge will treat it as a success even if the Status is Failed.

Throwing an OperationCanceledException

It is not recommended to throw an OperationCanceledException from your FooAction. When a Task throws an OperationCanceledException, it becomes Cancelled. Forge will rethrow the OperationCanceledException and halt/fail the tree walking session.

This is not recommended because it leaves no choice for the ForgeSchema author. If you instead return a Failed ActionResponse, Forge will continue walking the tree and you can look for that in the ForgeSchema and respond accordingly.

Throwing any other Exception

It's okay to throw Exceptions in your FooAction since Forge treats Exceptions as retriable failures. Doing this (as opposed to having internal retry logic or throwing OperationCanceledException) gives power to the ForgeSchema authors.

Forge will re-execute your FooAction depending on how the Timeout and RetryPolicy properties are set by the ForgeSchema author. If no RetryPolicy is set, then Forge will not re-execute your FooAction. In this case, if the ContinuationOnRetryExhaustion flag is set for this TreeAction, Forge will persist an ActionResponse on your behalf with Status == "RetryExhaustedOnAction". This is the recommended configuration if you do not want to retry the FooAction but want to continue walking the tree. (No RetryPolicy with ContinuationOnRetryExhaustion set)

If RetryPolicy and ContinuationOnRetryExhaustion are not set and an exception is thrown, then Forge will halt/fail. This is not recommended since the tree session ends abruptly and not on a Leaf node.

Timeout

Forge has built-in Timeout and RetryPolicy logic. It is okay for your FooAction to run for a long time if it needs to, without any internal timeouts. Similar to above, this gives power to the ForgeSchema authors to control the Timeout value.

If Forge hits the specified Timeout and the ContinuationOnTimeout flag is set, Forge will persist an ActionResponse on your behalf with Status == "TimeoutOnAction". This is the recommended configuration if you want to enforce a Timeout but want to continue walking the tree afterwards. (Timeout and ContinuationOnTimeout set)

If Timeout is set and ContinuationOnTimeout is not set, then Forge will halt/fail. This is not recommended since the tree session ends abruptly and not on a Leaf node.