Skip to content

How To: Author a ForgeTree

Travis Jensen edited this page Jun 5, 2020 · 29 revisions

This page provides a detailed guide how to author a ForgeTree and utilize the various features of Forge. It includes many samples, making it easy to copy/paste and adapt it for your scenarios. If you notice anything missing or incorrect, please use the "Issues" tab to bring it to attention, thank you!


Suggested Readings

Using the ForgeEditor

Creating your first ForgeTree

Creating a Subroutine Tree

Roslyn and C#|

Tips, Tricks, and Best Practices


Suggested Readings

Using the ForgeEditor

More details coming soon.

Creating your first ForgeTree

In this section we'll introduce ForgeTree properties one-by-one while going through a sample ForgeTree schema. By the end of this section, you should be able to comprehend and author basic ForgeTree schemas.

Be sure to read the other sections to learn more about Subroutines, Roslyn, and other tips, tricks, and best practices.

Comprehensive ForgeTree Example

{
    "RootTreeNodeKey": "Root",
    "Tree": {
        "Root": {
            "Type": "Selection",
            "ChildSelector": [
                {
                    "Label": "Container",
                    "ShouldSelect": "C#|UserContext.ResourceType == \"Container\"",
                    "Child": "Container"
                },
                {
                    "Label": "Node",
                    "ShouldSelect": "C#|UserContext.ResourceType == \"Node\"",
                    "Child": "Node"
                }
            ]
        },
        "Container": {
            "Type": "Action",
            "Actions": {
                "CollectDiagnosticsAction_Container": {
                    "Action": "CollectDiagnosticsAction"
                }
            },
            "ChildSelector": [
                {
                    "Label": "Tardigrade",
                    "ShouldSelect": "C#|Session.GetLastActionResponse().Status == \"Success\"",
                    "Child": "Tardigrade"
                }
            ]
        },
        "Tardigrade": {
            "Type": "Action",
            "Actions": {
                "TardigradeAction_Tardigrade": {
                    "Action": "TardigradeAction",
                    "Input": {
                        "Context": "ContainerFault",
                        "EnableV2": true,
                        "DiagnosticData": "C#|Session.GetLastActionResponse().Output"
                    }
                }
            },
            "ChildSelector": [
                {
                    "Label": "Tardigrade_Success",
                    "ShouldSelect": "C#|Session.GetOutput(\"Tardigrade_TardigradeAction\").Status == \"Success\"",
                    "Child": "Tardigrade_Success"
                },
                {
                    "Label": "Tardigrade_Failure",
                    "Child": "Tardigrade_Failure"
                }
            ]
        },
        "Tardigrade_Failure": {
            "Type": "Leaf"
        },
        "Tardigrade_Success": {
            "Type": "Leaf",
                "Actions": {
                    "LeafNodeSummaryAction_Tardigrade_Success": {
                        "Action": "LeafNodeSummaryAction",
                        "Input": {
                            "Status": "C#|string.Format(\"{0}_{1}\", \"ContainerFaultScenario\", (await Session.GetLastActionResponseAsync()).Status)",
                            "StatusCode": 0,
                            "Output": {
                                "ActionOutput": "C#|(await Session.GetLastActionResponseAsync()).Output",
                                "DiagnosticsOutput": "C#|(await Session.GetOutputAsync(\"Container_CollectDiagnosticsAction\")).Output"
                            }
                        }
                    }
                }
        },
        "Node": {
            "Type": "Selection",
            "Properties": {
                "Notes": "Decision to Reboot or Evacuate is decided in UserContext.ShouldReboot()."
            },
            "ChildSelector": [
                {
                    "Label": "Reboot",
                    "ShouldSelect": "C#|UserContext.ShouldReboot()",
                    "Child": "Reboot"
                },
                {
                    "Label": "Evacuate",
                    "Child": "Evacuate"
                }
            ]
        },
        "Reboot": {
            "Type": "Action",
            "Actions": {
                "RebootAction_Reboot": {
                    "Action": "RebootAction",
                    "RetryPolicy": {
                        "Type": "FixedCount",
                        "MaxRetryCount": 3
                    },
                    "ContinuationOnRetryExhaustion": true
                }
            }
        },
        "Evacuate": {
            "Type": "Action",
            "Actions": {
                "EvacuateAction_Evacuate": {
                    "Action": "EvacuateAction",
                    "Timeout": 60000,
                    "RetryPolicy": {
                        "Type": "ExponentialBackoff",
                        "MinBackoffMs": 1000,
                        "MaxBackoffMs": 30000
                    },
                    "ContinuationOnTimeout": true
                },
                "NotifyCustomerAction_Evacuate": {
                    "Action": "NotifyCustomerAction",
                    "Properties": "Notify customer in parallel with the impact."
                }
            },
            "Timeout": "C#|UserContext.GetTimeoutForEvacuatingAndNotifyingCustomer()"
        }
    }
}

ForgeTree.Tree and ForgeTree.RootTreeNodeKey

{
    "RootTreeNodeKey": "Root",
    "Tree": {
        "Root": {
ForgeTree.Tree

Tree is a dictionary that maps unique TreeNodeKey strings to TreeNodes. In the example we see several TreeNodeKeys, such as: Root, Container, Node, etc..

ForgeTree.RootTreeNodeKey

RootTreeNodeKey defines the suggested "Root" TreeNodeKey that should be visited first when walking the tree. Without this property, the callers of WalkTree would not know which TreeNodeKey to visit first since the TreeNodes are organized in a dictionary.

The default value of RootTreeNodeKey is "Root."

TreeNodeType.Selection and TreeNode.ChildSelector

        "Root": {
            "Type": "Selection",
            "ChildSelector": [
                {
                    "Label": "Container",
                    "ShouldSelect": "C#|UserContext.ResourceType == \"Container\"",
                    "Child": "Container"
                },
                {
                    "Label": "Node",
                    "ShouldSelect": "C#|UserContext.ResourceType == \"Node\"",
                    "Child": "Node"
                }
            ]
        },
TreeNodeType.Selection

Let's look at our first TreeNode, which is a Selection node. There are 4 TreeNodeTypes: Selection, Action, Leaf, and Subroutine. Selection type nodes have the following behavior:

  • ChildSelector property is defined. Attempts to select a child node to visit next.
  • Does not execute Actions.
  • Does not consume the TreeNode Timeout.
  • Shows up as a diamond in ForgeEditor.
TreeNode.ChildSelector

ChildSelectors define the connections to child TreeNodes, as well as the conditions required to visit each child. Several TreeNodeTypes can use ChildSelectors, including: Selection, Action, and Subroutine. Let's break down each property:

  • Label - This is used only in ForgeEditor for visualization purposes. It is the text that hovers above each child TreeNode. Recommend using this to describe the ShouldSelect statement. This gives context to why the child is being visited.
  • ShouldSelect - A string code-snippet that can be parsed and evaluated to a boolean value. If the expression is true, visit the attached Child TreeNode. If the expression is empty, evaluate to true by default. We'll dive more into "C#|" and Roslyn further down. For now, you can read the first ShouldSelect statement as follows: If the ResourceType equals Container, then visit the Container TreeNode.
  • Child - The string TreeNodeKey that will be visited if the attached ShouldSelect expression evaluates to true.

TreeNodeType.Action and TreeNode.Actions

        "Container": {
            "Type": "Action",
            "Actions": {
                "CollectDiagnosticsAction_Container": {
                    "Action": "CollectDiagnosticsAction"
                }
            },
            "ChildSelector": [
                {
                    "Label": "Tardigrade",
                    "ShouldSelect": "C#|Session.GetLastActionResponse().Status == \"Success\"",
                    "Child": "Tardigrade"
                }
            ]
        },
TreeNodeType.Action

Our next TreeNode is an Action node. Action type nodes have the following behavior:

  • Executes Actions. Must contain at least one Action.
  • If multiple Actions are defined, executes them all in parallel.
  • Cannot execute a SubroutineAction.
  • Optionally, ChildSelector can be defined. Child selection happens after executing Actions, as long as there are no unhandled exceptions/timeouts.
  • Optionally, TreeNode-level Timeout can be defined. This is the timeout in milliseconds for executing all TreeActions. If the timeout is hit, a TimeoutException will be thrown and the tree walker session will be cancelled.
  • Shows up as a rectangle in ForgeEditor.
TreeNode.Actions

Actions is a dictionary that maps unique TreeActionKey strings to TreeActions. In the example we see several TreeActionKeys, such as: CollectDiagnosticsAction_Container, TardigradeAction_Tardigrade, etc..

TreeActionKeys must be unique across each ForgeTree. This is required because Forge uses the TreeActionKey when persisting some state. The application owner could also decide to enforce globally unique TreeActionKeys. Global uniqueness allows TreeActionKeys by themselves to be a strong key, instead of having to couple it with TreeNode or TreeName.

TreeAction.Action and TreeAction.Input

        "Tardigrade": {
            "Type": "Action",
            "Actions": {
                "TardigradeAction_Tardigrade": {
                    "Action": "TardigradeAction",
                    "Input": {
                        "Context": "ContainerFault",
                        "EnableV2": true,
                        "DiagnosticData": "C#|Session.GetLastActionResponse().Output"
                    }
                }
            },
            "ChildSelector": [
                {
                    "Label": "Tardigrade_Success",
                    "ShouldSelect": "C#|Session.GetOutput(\"Tardigrade_TardigradeAction\").Status == \"Success\"",
                    "Child": "Tardigrade_Success"
                },
                {
                    "Label": "Tardigrade_Failure",
                    "Child": "Tardigrade_Failure"
                }
            ]
        },
TreeAction.Action aka ActionName

The string name that maps to a ForgeAction. In the example we see several ActionNames, such as: CollectDiagnosticsAction, TardigradeAction, LeafNodeSummaryAction, etc.. These all map to classes that have been tagged with the ForgeActionAttribute and inherit from Forge's BaseAction class.

More details here:

TreeAction.Input aka ActionInput

The Input property becomes the ActionInput object passed to the corresponding ForgeAction. Like other dynamic properties in the ForgeTree, this can be any supported JSON type including object, string, number, dictionary, array, etc.. Forge tree walker will dynamically evaluate the Input property while walking the tree, instantiate the object as the specified Type, and pass it to the ForgeAction.

The recommended way to utilize ActionInput is for the ForgeAction to specify the desired InputType in the ForgeActionAttribute. This allows Forge tree walker to create the desired Type object, and fill its properties from the TreeAction.Input. This has the benefit of type safety for the ForgeAction author and ForgeTree author, since they are using the same data contract.

The not recommended to utilize ActionInput is for the ForgeAction to not specify any InputType, but still allow the TreeAction.Input to be used. (Note: ForgeAction authors can choose which ForgeTree properties and values are allowed by utilizing the ForgeSchemaValidationRules if the application is utilizing that feature.) In this case, Forge will create a dynamic JObject from the TreeAction.Input. This is not recommended because you lose the type safety and data contract between ForgeAction author and ForgeTree author.

ForgeActions can specify to not have any InputType, like we saw in the CollectDiagnosticsAction.

More details here:

TreeAction.Input example

In this example, we see the Input for TardigradeAction with 3 properties: Context, EnableV2, and DiagnosticsData. So the TardigradeInput class could be defined like this:

    [ForgeAction(InputType: typeof(TardigradeInput))]
    public class TardigradeAction : BaseAction { ... }

    public class TardigradeInput
    {
        public string Context { get; set; }
        public bool EnableV2 { get; set; }
        public DiagnosticData DiagnosticData { get; set; }
        public long PollingIntervalInMilliseconds { get; set; } = 1000;
        public string AdditionalDetails { get; set; }
    }

Few things to note:

  • Notice the Types were aligned for the 3 properties specified in TreeAction.Input. Context given as a string, EnableV2 as a bool, and DiagnosticData given as DiagnosticData. Unexpected properties or types will likely result in an exception. E.g. An exception will be thrown if the TreeAction.Input tried to set DiagnosticData as a string, or tried to add an undefined property.
  • DiagnosticData came from a Roslyn expression that gets the ActionResponse.Output object from the CollectDiagnosticsAction. Not shown in the example is the ForgeAction defining the Output object as type DiagnosticData.
  • It is a common pattern in Forge to use either the results of previous ForgeActions or data from the UserContext as ActionInput.
  • Not all properties of TardigradeInput were used in the TreeAction.Input. Since Forge initializes the TardigradeInput object, the default value of PollingIntervalInMilliseconds will be set without having to specify in the TreeAction.Input.
  • AdditionalDetails has no default value and was not specified in the TreeAction.Input, so it will be null. The ForgeAction is expected to handle this gracefully, or the author should require the property to be specified in TreeAction.Input by utilizing the ForgeSchemaValidationRules.

TreeNodeType.Leaf

        "Tardigrade_Failure": {
            "Type": "Leaf"
        },
Leaf TreeNodeType

Our next TreeNode is a Leaf node. Leaf type nodes have the following behavior:

  • Only TreeNodeType that does not allow ChildSelectors.
  • Optionally can execute the native LeafNodeSummaryAction. No other ForgeActions can be executed on Leaf nodes, and only one LeafNodeSummaryAction can be executed.
  • Does not consume the TreeNode Timeout.
  • Shows up as a circle in ForgeEditor.

Since they cannot have children, Leaf nodes are used as a clear indicator for the end of tree paths.

Native LeafNodeSummaryAction

        "Tardigrade_Success": {
            "Type": "Leaf",
                "Actions": {
                    "LeafNodeSummaryAction_Tardigrade_Success": {
                        "Action": "LeafNodeSummaryAction",
                        "Input": {
                            "Status": "C#|string.Format(\"{0}_{1}\", \"ContainerFaultScenario\", (await Session.GetLastActionResponseAsync()).Status)",
                            "StatusCode": 0,
                            "Output": {
                                "ActionOutput": "C#|(await Session.GetLastActionResponseAsync()).Output",
                                "DiagnosticsOutput": "C#|(await Session.GetOutputAsync(\"Container_CollectDiagnosticsAction\")).Output"
                            }
                        }
                    }
                }
        },
LeafNodeSummaryAction

The LeafNodeSummaryAction is a native ForgeAction, and can be optionally executed from Leaf type TreeNodes.

This Action takes an ActionResponse as its ActionInput, either as an object or as properties, and commits this object as its ActionResponse.

This Action is intended to give schema authors the ability to cleanly end a tree walking path with a customized summary.

LeafNodeSummaryAction example

The Input is of type ActionResponse, which defines a string Status, int StatusCode, and object Output. In the example, we see a few different ways to set these properties.

  • Status is set as a Roslyn expression which combines the previous ActionResponse.Status with a hardcoded "scenario" string.
  • StatusCode is simply set to 0.
  • Output in this case is a dynamic object with 2 dynamically defined properties. ActionOutput is set as the previous ActionReponse.Output. DiagnosticsOutput is set as the ActionResponse.Output of Container_CollectDiagnosticsAction.

LeafNodeSummaryActions are particularly useful in Subroutines, since SubroutineActions return the GetLastActionResponse as its ActionResponse. So ending paths in a Subroutine Tree with a LeafNodeSummaryAction allows you to predictably set the ActionResponse of the calling SubroutineAction.

TreeNode.Properties

        "Node": {
            "Type": "Selection",
            "Properties": {
                "Notes": "Decision to Reboot or Evacuate is decided in UserContext.ShouldReboot()."
            },
            "ChildSelector": [
                {
                    "Label": "Reboot",
                    "ShouldSelect": "C#|UserContext.ShouldReboot()",
                    "Child": "Reboot"
                },
                {
                    "Label": "Evacuate",
                    "Child": "Evacuate"
                }
            ]
        },

TreeNode.Properties is a dynamic object that gets evaluated by Forge and passed to the Before/AfterVisitNode callbacks. This property allows new functionality to be seamlessly piped into the tree model and consumed by the application.

In this example, the application isn't actually consuming the object. Properties is being used to add comments to the JSON tree.

More details here:

TreeAction.RetryPolicy and TreeAction.ContinuationOnRetryExhaustion

        "Reboot": {
            "Type": "Action",
            "Actions": {
                "RebootAction_Reboot": {
                    "Action": "RebootAction",
                    "RetryPolicy": {
                        "Type": "FixedCount",
                        "MaxRetryCount": 3
                    },
                    "ContinuationOnRetryExhaustion": true
                }
            }
        },

More details here:

TreeAction.RetryPolicy

Our next TreeNode is an Action type node that defines a RetryPolicy. This is Forge's built-in retry functionality, and can be added to any ForgeAction. Whenever an exception is thrown while executing a ForgeAction, Forge tree walker will attempt to retry executing the Action according to the RetryPolicy and TreeAction.Timeout.

Note that there are several non-retriable exceptions that will cause Forge tree walker to immediately halt/fail, including:

  • OperationCanceledException - Thrown when cancellation token is triggered.
  • ActionTimeoutException - Thrown when TreeAction.Timeout is hit.
  • EvaluateDynamicPropertyException - Thrown when Forge hits an exception while evaluating schema properties. This is usually thrown when evaluating TreeAction.Input or ShouldSelect statements. It is recommended to write UTs to verify schema properties can be successfully evaluated.

Before adding a RetryPolicy to a ForgeAction, be sure to check with the ForgeAction author how they would like RetryPolicy to be utilized. Find more details in the above link regarding Forge behavior.

RetryPolicy Properties

Type - There are several types of retry policy available:

  • None - Do not retry. This is the default value.
  • FixedInterval - Retry at a fixed interval every MinBackoffMs.
  • ExponentialBackoff - Retry with an exponential backoff. Start with MinBackoffMs, then wait Math.Min(MinBackoffMs * 2^(retryCount), MaxBackoffMs).
  • FixedCount - Retry a fixed number of times based on RetryPolicy.MaxRetryCount. Wait RetryPolicy.MinBackoffMs between retries. (Note that Timeout values can be used with this retry type as well.)

-MinBackoffMs_ - Minimum backoff time in milliseconds. When retrying an action, wait at least this long before your next attempt. Set to 0 by default.

MaxBackoffMs - Maximum backoff time in milliseconds. When retrying an action, wait at most this long before your next attempt. Set to 0 by default.

MaxRetryCount - Maximum number of attempts to execute an action before failing. This property is only used when Type is RetryPolicyType.FixedCount. Default value is 1 (action runs only once and doesn't retry).

TreeAction.ContinuationOnRetryExhaustion

This boolean flag represents how to handle the exit of an action due to retry exhaustion. If false (default), then the tree walking session will halt/fail once retries are exhausted or no retries are specified. If true and retries are exhausted, the tree walking session will commit an ActionResponse with Status as "RetryExhaustedOnAction" before continuing on as if it were successful.

Use this flag when you expect a ForgeAction could fail, and you would like to continue walking the tree.

TreeNode.Timeout, TreeAction.Timeout and TreeAction.ContinuationOnTimeout

        "Evacuate": {
            "Type": "Action",
            "Timeout": "C#|UserContext.GetTimeoutForEvacuatingAndNotifyingCustomer()",
            "Actions": {
                "EvacuateAction_Evacuate": {
                    "Action": "EvacuateAction",
                    "Timeout": 60000,
                    "RetryPolicy": {
                        "Type": "ExponentialBackoff",
                        "MinBackoffMs": 1000,
                        "MaxBackoffMs": 30000
                    },
                    "ContinuationOnTimeout": true
                },
                "NotifyCustomerAction_Evacuate": {
                    "Action": "NotifyCustomerAction",
                    "Properties": "Notify customer in parallel with the impact."
                }
            }
        }
TreeNode.Timeout

Timeout in milliseconds for executing the TreeActions. Default to -1 (infinite) if not specified. A TimeoutException is thrown when this timeout is hit, causing the tree walking session to halt/fail.

This can be useful when you have several TreeActions set, but would like to enforce an uber timeout value for the entire TreeNode. Note that the "ContinuationOn*" flags do not affect the TreeNode.Timeout. Do not use TreeNode.Timeout if you wish to continue walking the tree when TreeActions get timed out.

In this example, we also see the Timeout value is set by a Roslyn expression. This shows it is possible to dynamically decide the timeout value at runtime.

TreeAction.Timeout

Timeout in milliseconds for executing the TreeAction. Default to -1 (infinite) if not specified.

This is Forge's built-in timeout functionality, and can be added to any ForgeAction. When the timeout is hit before the ForgeAction returns, an ActionTimeoutException will be thrown and the tree walker session will halt/fail. That is, unless the ContinuationOnTimeout flag is set.

Timeout and RetryPolicy can both be set on a TreeAction, or either can be set by itself. It is helpful to think of them as two separate concepts. In this example, we see the Action is given a 60 second timeout with retries that will continue successfully if the timeout is hit.

TreeAction.ContinuationOnTimeout

This boolean flag represents how to handle the exit of the action due to timeout. If false (default), then the tree walking session will halt/fail on the timeout. If true and a timeout is hit, the tree walking session will commit an ActionResponse with status as "TimeoutOnAction" before continuing on as if it were successful.

One interesting behavior to note is when Timeout is set, RetryPolicy is not set, and the Action fails before the timeout. In this case, it is considered a retry exhausted failure and not a Timeout failure. The ContinuationOnRetryExhaustion flag will be checked in this case, not the ContinuationOnTimeout flag.

Use this flag when you want to set a timeout limit on the ForgeAction execution time, and would like to continue walking the tree if the timeout is hit.

TreeAction.Properties

                "NotifyCustomerAction_Evacuate": {
                    "Action": "NotifyCustomerAction",
                    "Properties": "Notify customer in parallel with the impact."
                }

TreeAction.Properties is a dynamic object that gets evaluated by Forge and passed to the ForgeAction.

This is very similar to TreeNode.Properties except they are passed to different callbacks. There are less use-cases for TreeAction.Properties, since you will typically use TreeAction.Input to pass objects to ForgeActions.

In this example, we see Properties is set to a string. Properties can be a dynamic object, dictionary, array, string, number, bool, etc..

Creating a Subroutine Tree

In this section we'll talk about authoring Subroutine Trees and how to execute them with the native "SubroutineAction" ForgeAction.

More details here:

What is a Subroutine

A Subroutine Tree is just a ForgeTree that gets walked by Forge instead of the application. The ForgeTree that is walked from the application is called the "RootTree," and uses that as its TreeName by default. Additional ForgeTrees are referred to as Subroutine Trees, and are called via a SubroutineAction.

The SubroutineAction is a native ForgeAction that walks a tree walking session. The SubroutineInput is passed to the application to help it instantiate a tree walking session for the desired Subroutine Tree.

Benefits of Subroutines

  • Multi-file support - Use Subroutines to break up large ForgeTree schema files into multiple ForgeTrees over multiple files. If a ForgeTree gets too large, it can become more difficult to view in ForgeEditor and reason about intuitively. Editing large JSON files are also more prone to hitting merge conflicts. Having multiple files also allows you to update the files independently, enabling independent flighting or hotpatching.
  • Compartmentalization - Compartmentalize ForgeTree schema files by author or scenario. This is especially helpful when multiple contributors across multiple teams are authoring ForgeTrees. Having separate files per author or scenario helps reduce merge conflicts, and makes for simpler trees to view and comprehend.
  • Uber-Actions - Use Subroutines to create uber-Actions. For example, you want to achieve some behavior and a single TreeNode/ForgeAction isn't going to cut it. Perhaps whenever you execute a particular ForgeAction, you first want to run some prechecks. If the Action fails, you want to execute a fallback Action.. By placing this scenario into a Subroutine, it becomes easier for users to simply call the Subroutine versus calling the same TreeNode path (or worse, duplicating all the TreeNodes!). The Subroutine author has an easier time maintaining the behavior in a single location.
  • Parallelization - SubroutineActions can run in parallel with other ForgeActions, including other SubroutineActions.

Moving a path to a Subroutine Tree

{
    "NodeTree": {
        "RootTreeNodeKey": "NodeTreeRoot",
        "Tree": {
            "NodeTreeRoot": {
                "Type": "Selection",
                "ChildSelector": [
                    {
                        "Label": "Reboot",
                        "ShouldSelect": "C#|TreeInput.ShouldReboot",
                        "Child": "Reboot"
                    },
                    {
                        "Label": "Evacuate",
                        "Child": "Evacuate"
                    }
                ]
            },
            "Reboot": {
                "Type": "Action",
                "Actions": {
                    "RebootAction_Reboot": {
                        "Action": "RebootAction"
                    }
                }
            },
            "Evacuate": {
                "Type": "Action",
                "Actions": {
                    "EvacuateAction_Evacuate": {
                        "Action": "EvacuateAction"
                    },
                    "NotifyCustomerAction_Evacuate": {
                        "Action": "NotifyCustomerAction"
                    }
                }
            }
        }
    }
}

Let's take the "Node" TreeNode path from the earlier creating your first ForgeTree example, and move it to its own Subroutine.

The TreeNodes have been simplified in this example for brevity, but you can see the ForgeTree/TreeNodes are the same as the previous example.

Subroutine TreeName
{
    "NodeTree": {
        "RootTreeNodeKey": "NodeTreeRoot",
        "Tree": {

In this example we see an extra Dictionary added on top of the ForgeTree. The TreeName key is "NodeTree," and it maps to a ForgeTree. Compare this to the creating your first ForgeTree example from earlier that does not have this additional layer. What's going on here?

This is the recommended way to store multiple ForgeTrees, as a dictionary of TreeName to ForgeTree. The application owner will need to take care of properly handling this data structure and passing the correct ForgeTree to each tree walking session.

The TreeName key you set in your Subroutine is the same TreeName value used in the SubroutineAction. Be sure to use a meaningful name to describe your Subroutine.

Subroutine ForgeTree.RootTreeNodeKey

The RootTreeNodeKey is the TreeNode key that should be visited first when walking the tree. The default value is "Root" if not given.

Since the ForgeTree.Tree is a Dictionary, it's difficult for an application to know which TreeNode to start on. Hence the necessity of defining it explicitly with this property. The SubroutineAction also relies on this property to know which TreeNode to start on.

Subroutine TreeInput
                "ChildSelector": [
                    {
                        "Label": "Reboot",
                        "ShouldSelect": "C#|TreeInput.ShouldReboot",
                        "Child": "Reboot"
                    },

TreeInput is a dynamic object that gets passed to the Subroutine session from the SubroutineAction. This "TreeInput" object is able to be referenced when evaluating Roslyn expressions in the Subroutine Tree. Think of it similarly to ActionInput for ForgeActions.

Each Subroutine Tree can use TreeInput in their own way, such as a string, dictionary, or object. It is up to you as the Subroutine author to decide the data structure of the TreeInput, or to not use it at all. However you reference the TreeInput in your Subroutine Tree will dictate the SubroutineInput.TreeInput object that must be passed in the SubroutineAction.

In this example, we are treating TreeInput as an object that contains a boolean ShouldReboot property. So whenever a SubroutineAction calls your "NodeTree" Subroutine, they must include a TreeInput object with a boolean ShouldReboot property.

More details here:

Returning a custom ActionResponse from your Subroutine

The ActionReponse of the SubroutineAction is the Subroutine session's last persisted ActionResponse. i.e. The ActionResponse of the ForgeAction that was executed last. If no ForgeActions were executed in the Subroutine, then the TreeWalkerSession.Status will be used as a fallback.

In this example, if the "Reboot" TreeNode was visited last, then the RebootAction_Reboot's ActionResponse would be the ActionResponse of the SubroutineAction.

If instead the "Evacuate" TreeNode was visited last, then either the EvacuateAction_Evacuate or NotifyCustomerAction_Evacuate ActionResponse would be used. There is some uncertainty here because the Actions run in parallel, so either of them could finish last.

To clear up any confusion, it is recommended to end Subroutine sessions with Leaf nodes executing the LeafNodeSummaryAction. This enables you to hand-craft the ActionResponse returned by your Subroutine.

Using the Native SubroutineAction

            "Node": {
                "Type": "Subroutine",
                "Actions": {
                    "SubroutineAction_Node": {
                        "Action": "SubroutineAction",
                        "Input": {
                            "TreeName": "NodeTree",
                            "TreeInput": {
                                "ShouldReboot": "C#|UserContext.ShouldReboot()"
                            }
                        }
                    }
                }
            },

Let's talk about how to call a Subroutine Tree using the SubroutineAction. In this example, we're updating the "Node" TreeNode from the Creating your first ForgeTree tree, and converting it into a Subroutine type TreeNode. The SubroutineAction calls the "NodeTree" Subroutine Tree we created above.

TreeNodeType.Subroutine
            "Node": {
                "Type": "Subroutine",

Subroutine type nodes have the following behavior:

  • Executes SubroutineActions, as well as ForgeActions. Must contain at least one SubroutineAction.
  • If multiple Actions are defined, executes them all in parallel.
  • Optionally, ChildSelector can be defined. Child selection happens after executing Actions, as long as there are no unhandled exceptions/timeouts.
  • Optionally, TreeNode-level Timeout can be defined. This is the timeout in milliseconds for executing all TreeActions. If the timeout is hit, a TimeoutException will be thrown and the tree walker session will be cancelled.
  • Shows up as a rectangle in ForgeEditor.
Native SubroutineAction
                        "Action": "SubroutineAction",

Execute this native ForgeAction by setting the Action name to "SubroutineAction."

SubroutineInput.TreeName
                        "Input": {
                            "TreeName": "NodeTree",

The value of SubroutineInput.TreeName should be equal to the Subroutine TreeName you want to walk. This can be any TreeName known by the application, including the RootTree or your current Subroutine's TreeName. Sync with the application owner to get the complete list of TreeNames available.

In this example, "NodeTree" is used in the TreeName, which maps to the NodeTree Subroutine Tree we created in earlier examples.

More details here:

SubroutineInput.TreeInput
                            "TreeInput": {
                                "ShouldReboot": "C#|UserContext.ShouldReboot()"
                            }

SubroutineInput.TreeInput is a dynamic object that gets passed to the Subroutine session. This "TreeInput" object is able to be referenced when evaluating Roslyn expressions in the Subroutine Tree. Think of it similarly to ActionInput for ForgeActions.

Each Subroutine Tree can use TreeInput in their own way, such as a string, dictionary, object, or null. Be sure to investigate the Subroutine Tree or sync up with the Subroutine author to make sure the TreeInput object you pass in is of the expected type.

In this example, we are calling the "NodeTree" Subroutine Tree. Because we authored that Subroutine, we know that the TreeInput object is expected to have a boolean ShouldReboot property.

More details here:

SubroutineInput.TreeFilePath

This optional property is used in applications that don't have the ForgeTree files cached. A sample use-case could be an application that gets sent ForgeTree schema files to walk and doesn't maintain them itself.

If this feature is used, it is up to the application owner to handle reading this property and fetching the ForgeTree schema file from the given location.

If you are unsure if this property is required, sync with the application owner to confirm.

Roslyn and C#|

In this section, we'll talk about Roslyn, how to write expressions using C#|, and the various objects you have access to in your expressions.

How Forge utilizes Roslyn

Roslyn integration overview

The Roslyn integration is a key feature of Forge that makes it highly dynamic and extensible.

Forge will dynamically compile and run C# code snippets starting with "C#|" using Roslyn. The Roslyn code has access to the Forge Session (which holds all the persisted state output from Actions), Forge UserContext (the dynamic app-defined object that can have direct access to your application), and Forge TreeInput (the dynamic object passed in by the application or evaluated from the SubroutineInput).

Most properties of the ForgeTree can be dynamically evaluated at runtime. The most common properties to utilize Roslyn are ShouldSelect and ActionInput.

More details here:

Implementation details of Roslyn integration

This section walks through the implementation details of when and how Forge utilizes Roslyn.

  • As Forge TreeWalkerSession walks a tree, it calls callbacks at each TreeNode and TreeAction. Callbacks such as BeforeVisitNode, AfterVisitNode, and ForgeActions. These callbacks take in properties from the ForgeTree, which Forge will dynamically evaluate to create the necessary objects. ForgeActions, for example, take in the TreeAction.Input property.
  • Any properties that are prefixed with "C#|" are special cased to be evaluated using Roslyn. The property value becomes the result of that expression.
  • The code-snippet that gets evaluated is generated as follows, where the expression is the substring that follows "C#|". This simplifies expressions since they all must return a value. This also prevents users from easily/accidentally setting state, which could be dangerous.
string.Format("return {0};", expression)

Using Session in your Roslyn expressions

Session is the ITreeSession.cs interface of the tree walking session. It has access to the persisted ActionResponses with the following method headers:

  • ActionResponse GetOutput(string treeActionKey);
  • Task GetOutputAsync(string treeActionKey);
  • ActionResponse GetLastActionResponse();
  • Task GetLastActionResponseAsync();

Note that Subroutines are their own tree walking session, so you cannot use Session to access parent session's state. For example, you execute an Action is the RootTree and call a Subroutine. While inside the Subroutine, your Session object cannot access the Action executed in the RootTree.

ActionResponse

Every ForgeAction returns an ActionResponse that gets persisted in Forge's persisted state. These ActionResponses can then be accessed in ForgeTree expressions through the "Session" interface.

ActionResponse objects contain string Status, int StatusCode, and object Output properties.

It's up to the application owner and ForgeAction authors to decide how to fill these properties. It is generally recommended to have a common pattern for setting the Status and StatusCode across ForgeActions, such as using an enum class to indicate Success/Fail/Timeout. Each ForgeAction may use their own custom Types in their Output property, so check the ForgeAction or check with the author to figure out the Type.

Session.GetLastActionResponse
        "Container": {
            "Type": "Action",
            "Actions": {
                "CollectDiagnosticsAction_Container": {
                    "Action": "CollectDiagnosticsAction"
                }
            },
            "ChildSelector": [
                {
                    "Label": "Tardigrade",
                    "ShouldSelect": "C#|Session.GetLastActionResponse().Status == \"Success\"",
                    "Child": "Tardigrade"
                }
            ]
        },

Session.GetLastActionResponse gets the last persisted ActionResponse on that tree walking session.

This method is best used when there is no ambiguity about which ForgeAction was executed last. In the example, we are using GetLastActionResponse in the ChildSelector of an Action type node. This will return the ActionResponse of the CollectDiagnosticsAction. We know this is the last committed ActionResponse because it's on the same TreeNode, and Actions are executed and persisted before hitting the ChildSelectors.

Session.GetLastActionResponseAsync
        "Tardigrade_Success": {
            "Type": "Leaf",
            "Actions": {
                "LeafNodeSummaryAction_Tardigrade_Success": {
                    "Action": "LeafNodeSummaryAction",
                    "Input": {
                        "Status": "C#|string.Format(\"{0}_{1}\", \"ContainerFaultScenario\", (await Session.GetLastActionResponseAsync()).Status)",
                        "StatusCode": 0,
                        "Output": {
                            "ActionOutput": "C#|(await Session.GetLastActionResponseAsync()).Output",
                            "DiagnosticsOutput": "C#|(await Session.GetOutputAsync(\"Container_CollectDiagnosticsAction\")).Output"
                        }
                    }
                }
            }
        },

The asynchronous version of GetLastActionResponse. You'll need to use await to get the result when using this method.

In the example, we use GetLastActionResponseAsync to grab the Status and the Output.

Session.GetOutput
            "ChildSelector": [
                {
                    "Label": "Tardigrade_Success",
                    "ShouldSelect": "C#|Session.GetOutput(\"Tardigrade_TardigradeAction\").Status == \"Success\"",
                    "Child": "Tardigrade_Success"
                },
                {
                    "Label": "Tardigrade_Failure",
                    "Child": "Tardigrade_Failure"
                }
            ]
        },

Session.GetOutput(string treeActionKey) is used to get the ActionResponse of the given TreeAction key. Use this method to get ActionResponses that were persisted earlier in the tree walking session.

In the example, we are using GetOutput to get the previously executed ForgeAction with the TreeAction key "Tardigrade_TardigradeAction."

Session.GetOutputAsync

Session.GetOutputAsync is the asynchronous version of GetOutput.

In the example, we are using GetOutputAsync to get the previously executed ForgeAction with the TreeAction key "Container_CollectDiagnosticsAction."

Using UserContext in your Roslyn expressions

More details here:

What is UserContext

The UserContext is an object defined in the 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.

It is up to the application owner to decide what goes into the UserContext, so check with them to get more details. They could add references to their application's data models, internal microservices/classes, external service clients, session-specific information, etc..

Using UserContext examples
  • Grab property values from the UserContext. In this example, the UserContext has a ResourceType property that tells us more about this session-specific data. We are using the Root TreeNode here to switch paths for each supported ResourceType.
        "Root": {
            "Type": "Selection",
            "ChildSelector": [
                {
                    "Label": "Container",
                    "ShouldSelect": "C#|UserContext.ResourceType == \"Container\"",
                    "Child": "Container"
                },
                {
                    "Label": "Node",
                    "ShouldSelect": "C#|UserContext.ResourceType == \"Node\"",
                    "Child": "Node"
                }
            ]
        },
  • Call helper methods in the UserContext. In this example, GenerateRandomNumber is called to do AB testing by going down PathA 25% of the time.
            "ChildSelector": [
                {
                    "Label": "PathA",
                    "ShouldSelect": "C#|UserContext.GenerateRandomNumber(100) < 25",
                    "Child": "PathA"
                },
                {
                    "Label": "PathB",
                    "Child": "PathB"
                }
            ]
  • Call into the application's data model to get realtime data about the resources. In this example, ContainerData is a hook into the source-of-truth Container resource information for this session-specific request. As Forge is walking the tree and gets to this ChildSelector, it can access this information at runtime to get the latest State of the Container. If the State is Healthy, tree walker visits PathA, otherwise PathB.
            "ChildSelector": [
                {
                    "Label": "PathA",
                    "ShouldSelect": "C#|UserContext.ContainerData.State == \"Healthy\"",
                    "Child": "PathA"
                },
                {
                    "Label": "PathB",
                    "Child": "PathB"
                }
            ]
Extending UserContext

As a ForgeTree author, you have access to this UserContext object in your Roslyn expressions. It is also typical to add 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.

General guidelines for when to create helper methods:

  • To simplify a complicated statement containing conditions that you don't expect to change. For example, if you want to check if a resource is healthy, and that requires you to check multiple values against multiple properties.
// Before
"ShouldSelect": "UserContext.NodeData.AvailabilityState == \"Available\" and !(new System.Collections.Generic.List<string>{\"Recovering\", \"Booting\", \"OutForRepair\"}).Contains(UserContext.NodeData.State)"
// After
"ShouldSelect": "C#|await UserContext.IsNodeHealthy()"
  • To improve code readability or reduce references. For example, creating a GenerateRandomNumber method that uses the RequestIdentifier as a seed (to get consistent results). Notice that using the method is more human readable and reduces the inputs.
// Before
"ShouldSelect": "C#|new Random(UserContext.RequestIdentifier).Next(100)",
// After
"ShouldSelect": "C#|GenerateRandomNumber(100)",

Using TreeInput in your Roslyn expressions

TreeInput is another dynamic object you have access to in Roslyn expressions. It holds the TreeInput given to the tree walking session, typically by the SubroutineAction via SubroutineInput.TreeInput.

More details here:

TreeInput example from UnitTests
{
    "RootTree": {
        "Tree": {
            "Root": {
                "Type": "Action",
                "Actions": {
                    "Tardigrade_TardigradeAction": {
                        "Action": "TardigradeAction"
                    }
                },
                "ChildSelector": [
                    {
                        "Label": "Tardigrade_Success",
                        "ShouldSelect": "C#|Session.GetLastActionResponse().Status == \"Success\"",
                        "Child": "Tardigrade_Success"
                    }
                ]
            },
            "Tardigrade_Success": {
                "Type": "Subroutine",
                "Actions": {
                    "Root_Subroutine": {
                        "Action": "SubroutineAction",
                        "Input": {
                            "TreeName": "SubroutineTree",
                            "TreeInput": {
                                "Key1": "C#|Session.GetLastActionResponse()",
                                "Key2": "C#<Collections.Generic.IDictionary`2[System.String,System.String]>|UserContext.GetDictionary()",
                                "Key3": "C#|UserContext.GetCustomObjectDictionary()",
                                "Key4": [
                                    "C#|UserContext.CustomObject",
                                    {
                                        "Command": "MyCommand"
                                    }
                                ],
                                "Key5": {
                                    "NestedKey1": "C#|UserContext.CustomObject",
                                    "NestedKey2": {
                                        "Command": "MyOtherCommand"
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
    },
    "SubroutineTree": {
        "Tree": {
            "Root": {
                "Type": "Leaf",
                "Actions": {
                    "Root_LeafNodeSummaryAction": {
                        "Action": "LeafNodeSummaryAction",
                        "Input": {
                            "Status": "C#|(string)TreeInput.Key1.Status + TreeInput.Key2[\"Key2\"] + TreeInput.Key3[\"Key1\"].Command + TreeInput.Key4[0].Command + TreeInput.Key4[1].Command + TreeInput.Key5[\"NestedKey1\"].Command + TreeInput.Key5[\"NestedKey2\"].Command"
                        }
                    }
                }
            }
        }
    }
}

This exaggerated example shows many of the ways you can create complex objects in Forge, and then access their properties.

More details here:

Using Dependencies in your Roslyn expressions

TreeWalkerParameters.Dependencies

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.

More details here:

Using Dependencies examples

Add a Type Dependency when you need to use the type directly in a Roslyn expression. For example, when referencing an enum:

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()"
}

Or when casting a specific type:

{
    "ShouldSelect": "C#|UserContext.GetActionResponseOutput<AbTestOutput>(\"Run_AbTestAction\").ChosenAction == \"NoOp\""
}

Tips, Tricks, and Best Practices

More details coming soon.

Clone this wiki locally