Lambda Execution

Lambda Definition

In the Condense framework, a lambda is a method executed in response to one or more events. This method is a declared in a domain assembly, and marked with the Condense.Core.Lambda attribute.

Further information can be declared using the properties on this attribute, as well as per-input attributes on parameters (Condense.Core.Param).

This rather long document describes the steps that the Condense runtime performs. It describes when your method is executed, how it fills the parameters, and importantly how you can control this. Discussions on work queues and transactional isolation are purely informative, and inline with the zero-boilerplate approach we only require:

  • The method definition and body.
  • An attribute to flag that this method is a lambda method.
  • Attributes to describe any variance from the defaults behavior.

Whenever we have both a credit report and identity document for a customer, we will set their approval status to approved, if they score more than 100 on the ID check and are not a credit fraud.

This method implements the entirety of the requirement.

[Lambda(ContextType=typeof(Customer))]
public static CustApprovalStatus UpdateApprovalStatus(IdentityDocument id, CreditReport cr) 
{
	return new CustApprovalStatus { Approved = id.Score > 100 && cr.NotFraudy };
}

Signature

class TService {
    [Lambda(
	    {optional ContextType=typeof(TContext),}
	    {optional DeferSeconds=...}
	    ...] 
	    public TOutput ActionName(
		    [Param(...)]
		    TInput parameterName,
		    ...)
}

or

[Entity] class TContext {
    
    ...
        
    [Lambda(...)] TOutput ActionName(...);        
}

The signature of a lambda method and it’s attributes comprise the entire description of the circumstances in which it will be executed, and the rules which will apply to filling it’s parameters. The defaults are chosen to be sensible, and avoid boilerplate.

The unique combination of method key and input types form the identity of the lambda. This is important when running multiple concurrent versions of the domain, as only one of the multiple versions will execute for a given trigger.

A lambda’s may return any number of output entities: none (void), a single result (TOutput), or multiple (IEnumerable).

A lambda will be executed when one of it’s inputs is created (or a new revision created), and the remaining parameters can be filled by previous events that meet the specified restrictions, typically by having a shared lineage of the specified context type (TContext). If Defer is set the attempt to execute it will be deferred. This deferral is both persistent and reliable, ranging from minutes to months. Parameters will be checked and filled at the time of execution, so it is important that any entity retention times make sense.

The lambda’s execution is a single atomic unit of work. Upon successful execution the outputs are committed to storage, however in the case of an exception the work is rolled back. The execution may be retried later, or the execution request dead-lettered.

Triggering Execution Requests from Outputs

Starting at the finish: Upon successful execution of a lambda method, the output entity is decorated with framework metadata and sent to storage. The arrival of data is processed by the subscription service, and subscribers notified. For each output we then interrogate the domain to find any subsequent lambdas that might be triggered by this method. A lambda execution request is then created, specifying the identity of the lambda and the triggering entity. This is then enqueued on the domain work queue (or deferred if appropriate).

Work queues

The work queue is a distributor which hands out execution requests to many worker threads, running on many worker-nodes, for a given runtime. Typically using the development host these numbers will both be one, however this is unbounded on a cloud runtime.

Condense Cloud is currently implemented using Amazon SQS for both work queue and subscription services, with a single message per execution request.


A single logical queue may in future be upgraded to a multiple-queue system to allow different service levels for different lambdas.

Execution Plans

A worker receives an execution request. This contains the identity of both the lambda to execute and the triggering entity.

If multiple versions of the domain exist, the worker will attempt to execute the most recent version first, and continue in order.

A UnitOfWork is created for both the planning and execution of the lambda. This will be committed upon success, or rolled back on failure.

Establishing context

If a context type (TContext) is specified for the lambda then the runtime will attempt to establish a context for performing data retrieval.

If the triggering entity can be coerced to the context type (typically by inheritance, but also see [version coercion]) then it will be set as the context. If not then the runtime will work it’s way up the trigger’s lineage until it finds an appropriate entity which can be coerced to the context type to set as the context.

If a context type is specified and one cannot be found, then the execution is abandoned.

Filling inputs

Each input will now be filled according to the rules specified. By default, the runtime will select the latest revision of the most recent entity in context which can be coerced to the input type. This is the same as (and implemented by) IUnitOfWork.Get<TInput>(). This search respects entity inheritance (covariance).

If an entity cannot be found, the execution will be abandoned (although see [below]).

Advanced input options and usage

There are some restrictions in what is considered an appropriate input. In the case that data is changing rapidly, the runtime will by default abandon an execution plan if the trigger is stale - the remaining parameters are more recent than the trigger. This prevents double-execution in several undesirable situations, notably Foo(x) => (A, B) triggering Bar(A,B).

Using the Condense.Core.Param attribute on an input, several modifiers to how it is handled may be applied:

  • bool NonTriggering - Execution of the lambda will not be triggered by the arrival or update of this entity type.

  • bool AllowNull - Execution will not be abandoned if this entity cannot be found. The method will be passed null.

  • bool MustBeNull - Execution will abandoned if this parameter is found. This is useful when implementing the pattern: TResponse X(TRequest, [MustBeNull] TResponse)

  • TimeSpan? Recency - Only entities that have been created within this window will be considered for the parameter. This time is as of execution.

  • Type[] WithDescendant - Only entities that have an entity of one of these types as a descendant will be considered. This test is covariant. This is intended for usage in cases such as a “purchase order which has caused a shipment”.

  • Type[] ExcludeWithDescendant - Entities with any one of these types as a descendant will not be considered. This test is also covariant. Intention: “purchase order which has not yet caused a shipment”.

  • TimeSpan? NullAfter - The lambda will trigger immediately, and attempt to execute with AllowNull = false. An additional execution attempt will be deferred with MustBeNull = true for the appropriate time. This allows business logic to “timeout” when waiting for information. This uses the common lambda deferral mechanism which is reliable and cluster-aware, allowing values ranging from minutes to months.

Several of these options are intended to prevent extra execution when using entities as ‘convenience parameters’. While it is primarily intended that a “lambda updates the output when the inputs change”, sometimes the input is added for convenience. We would not want to reship every invoice when a client’s address changes (if this was a parameter) for example. This can be controlled by setting the parameter to NonTriggering, preventing use of data you consider stale with Recency, or using MustBeNull / ExcludeWithDescendant to control execution based on existing outputs.

TimeSpan types cannot be set with attribute properties, so convenience properties exist such as NullAfterSeconds/NullAfterDays.

Version coercion

The Param attribute also contains version coercion rules. This relates the code-version of the lambda method to the code-version of the entity being considered as an input, at the time it was stored. Only the entity retrieved using the context-based retrieval will be considered, (eg: The latest relevant T). If this cannot be coerced, then the input will not be filled.

The VersionMatch enumeration restricts which code-version(s) of the entity will be considered, in terms of Any, Major, Minor, or Exact. For a non-exact match the entity will be coerced to the code-version of the lambda, either upwards or downwards, as long as the appropriate AllowUpgrade/Downgrade is set.

As an example, given the signature:

[Lambda(ContextType=typeof(Client))] void HandlePayout(Contract c, Payout p)

When running multiple versions of our domain simultaneously, we may decide that this business logic should be handed by the same assembly version of the code which created the contract. In this case we modify the declaration to reflect this rule:

[Lambda(ContextType=typeof(Client))] void HandlePayout(
	[Param(VersionMatch=VersionMatch.Exact)] Contract c, 
	[Param(VersionMatch=VersionMatch.Any, VersionAllowDowngrade=true)] Payout p
	)

In this case, running both v1 and v2 of our domain, the arrival of a Payout v2 will trigger the HandlePayout lambda.

HandlePayout v2 will first be considered. If the contextual Contract is version 1, then execution will be abandoned. HandlePayout v1 will then be considered, the Contract c parameter will match. The Payout v2 will be coerced down to Payout v1, and execution will proceed.


This coercion process will drop appropriate fields and initialize new ones with defaults. Deeper control over this may be expanded on.

A type can be coerced between versions and variants simultaneously. A Payout v1 entity is able to be coerced to a BasePayout v2 (assuming Payout v2 : BasePayout v2). The framework will first identify the matching type of the target version (v2 => Payout v2) and then attempt to find any covariant match. This has the benefit that the type BasePayout v1 does not need to exist - you can generally increase the complexity of your domain, and write more specific lambda methods to deal with this complexity, and coercion will just work.

Execution

If an execution plan has been filled, the runtime will enter a new lambda execution context. This contains a reference to the plan, and any formal inputs to the execution. Entities stored using IUnitOfWork.Put and entity outputs will pick up the identity of the lambda as their cause, and the inputs as part of their lineage. This is used to create an execution record detailing the execution.

If the lambda method requires an instance of a service class, then an instance will be requested from the host, allowing it to be provisioned via IoC if needed.

The execution context will also pick up any logging written to Debug (via Trace). The runtime adapts Common.Logging to write through this pipeline. This logging is stored in the execution record.

Unit of Work

Between the entry and exit of the lambda method, there is an ambient unit of work available via Ambient<IUnitOfWork>.Current. This is used by the framework to resolve indirect Reference<T>, and to query the domain further. This is usable by any code that your lambda expression calls.

Any entities read “manually” from the unit of work are considered indirect inputs. These are noticed by the lambda execution context, and their reference will be stored with the execution record.

Exceptions

Generally speaking, a lambda method throwing an exception is unusual. It indicates an error processing the execution plan, and the framework will perform the following recovery steps:

  • The work performed by the lambda function is rolled back.
  • The execution request will be re-attempted.
  • Continued failures may result in a ‘dead letter’ situation for the request.

This allows you to avoid handling common errors caused by transient faults (eg: a network service not available) by simply allowing exceptions to bubble up to be handled by the framework.

Logical Errors

That said, given certain inputs, a logical error may be the correct result. In your business logic you may wish to raise an error, possibly from deeper in the call stack. In this case throwing a Condense.Core.ErrorException will indicate to the framework that while a logical error occurred, the framework should not treat this as a failure. This error may contain none or many entities which represent the error.

  • The work performed by the lambda function will be rolled back.
  • The execution will be considered to have completed, but will be flagged as faulted.
  • The contents of the ErrorException.Error property will be considered the result.
  • These results may trigger additional lambda methods
if(approvedAmountFarTooHigh)
    throw new ErrorException(new LoanResult { Approved = false, Reason = "Declined due to safeguard violation" }); 

Outputs

Entities returned by a lambda method, either singular or multiple, are stored. Enumerations are unrolled, to allow use of the yield return syntax. Each output then is then, as above, examined for possible triggers, relevant execution requests enqueued (or chained, see below).

Finally the unit of work is then committed.

Post-commit, the subscription service is notified of new entities.

Chaining

A lambda tagged with [Lambda(Chaining=true)] may bypass the execution queue. This is an optimization to avoid the round-trip delay on very simple requests. In this case the runtime will try to fill the execution plan in the same unit of work, and execute it.

This is designed for chaining basic logic such as “when we have a Project Completion create an Email Notification”, with the Email Notification later triggering a lambda which actually sends the email. Importantly here, if the chained execution throws an exception, or otherwise votes for a rollback, then the entire unit of work will be forced to roll back.

Execution Records

On success, an execution record is stored. This contains the identity of the lambda executed, the inputs, indirect inputs, outputs, debug information, etc.

This information, in conjunction with entity revisioning, allows Condense to provide a completely traceable and replayable picture of every piece of business logic which executed in the domain. Forthcoming analytics are also able to provide aggregate analysis of this information.