Skip to content

Latest commit

 

History

History
327 lines (233 loc) · 19 KB

COMBINATORS.md

File metadata and controls

327 lines (233 loc) · 19 KB

Combinators

The composer module offers a number of combinators to define compositions:

Combinator Description Example
action action composer.action('echo')
asynchronous asynchronous invocation composer.asynchronous('compress', 'upload')
doloop and doloop_nosave loop at least once composer.doloop('fetchData', 'needMoreData')
empty empty sequence composer.empty()
ensure finalization composer.ensure('tryThis', 'doThatAlways')
function function composer.function(lambda env, args: { 'product': {args['x'] * args['y'] })
when and when_nosave conditional composer.when('authenticate', 'success', 'failure')
let variable declarations composer.let({ 'count': 3, 'message': 'hello' }, ...)
literal or value constant value composer.literal({ 'message': 'Hello, World!' })
mask variable hiding composer.let({ n }, composer.loop(lambda env, _: env['n']-- > 0, composer.mask(composition)))
merge data augmentation composer.merge('hash')
repeat counted loop composer.repeat(3, 'hello')
retain and retain_catch persistence composer.retain('validateInput')
retry error recovery composer.retry(3, 'connect')
sequence or seq sequence composer.sequence('hello', 'bye')
task single task composer.task('echo')
do error handling composer.do('divideByN', 'NaN')
loop and loop_nosave loop composer.loop('notEnough', 'doMore')

The action, function, and literal combinators construct compositions respectively from OpenWhisk actions, Python functions and lambdas, and constant values. The other combinators combine existing compositions to produce new compositions.

Shorthands

Where a composition is expected, the following shorthands are permitted:

  • name of type string stands for composer.action(name),
  • fun of type function stands for composer.function(fun),
  • None stands for the empty sequence composer.empty().

Action

composer.action(name, [options]) is a composition with a single action named name. It invokes the action named name on the input parameter object for the composition and returns the output parameter object of this action invocation.

The action name may specify the namespace and/or package containing the action following the usual OpenWhisk grammar. If no namespace is specified, the default namespace is assumed. If no package is specified, the default package is assumed.

Examples:

composer.action('hello')
composer.action('myPackage/myAction')
composer.action('/whisk.system/utils/echo')

The optional options dictionary makes it possible to provide a definition for the action being composed.

# specify the code for the action as a function reference
def hello(env, args):
    return { 'message': 'hello' }

composer.action('hello', { 'action': hello })

# specify the code for the action as a string
composer.action('hello', { 'action': "message = 'hello'\ndef main(env, args):\n    return { 'message':message }" })

# specify the code and runtime for the action
composer.action('hello', {
    'action': {
        'kind': 'python:3',
        'code': "def function(env, args):\n    return { 'message': 'hello' }"
    }
})

# specify a file containing the code for the action
composer.action('hello', { 'filename': 'hello.py' })

# specify a sequence of actions
composer.action('helloAndBye', { 'sequence': ['hello', 'bye'] })

The action may be defined by providing the code for the action as a string, as a Python function, or as a file name. Alternatively, a sequence action may be defined by providing the list of sequenced actions. The code (specified as a string) may be annotated with the kind of the action runtime.

Environment capture

Python functions used to define actions cannot capture any part of their declaration environment. The following code is not correct as the declaration of name would not be available at invocation time:

name = 'Dave'
def main(env, args):
  return { 'message': 'Hello ' + name }

composer.action('hello', { 'action': main )

In contrast, the following code is correct as it resolves name's value at composition time.

name = 'Dave'
composer.action('hello', { 'action': 'lambda env, args: { "message": "Hello ' + name +'"}' })

Function

composer.function(fun) is a composition with a single Python function fun. It applies the specified function to the environment, and the input parameter object for the composition.

  • If the function returns a value of type function, the composition returns an error object.
  • If the function throws an exception, the composition returns an error object. The exception is logged as part of the conductor action invocation.
  • If the function returns a value of type other than function, the value is first converted to a string using str followed by json.loads. If the resulting JSON value is not a JSON dictionary, the JSON value is then wrapped into a { value } dictionary. The composition returns the final JSON dictionary. (Not supported yet)
  • If the function does not return a value and does not throw an exception, the composition returns the input parameter object for the composition converted to a string using str followed by json.loads.

Examples:

composer.function(lambda env, args: { 'message': 'Hello ' + params['name'] })

composer.function(lambda env, args: { 'error': 'error' })

def product(env, params):
    return { 'product': params['x'] * params['y'] }
composer.function(product)

Literal

composer.literal(value) and its synonymous composer.value(value) output a constant JSON dictionary. This dictionary is obtained by first converting the value argument to JSON using str followed by json.loads. If the resulting JSON value is not a JSON dictionary, the JSON value is then wrapped into a { value } dictionary.

The value argument may be computed at composition time. For instance, the following composition captures the date at the time the composition is encoded to JSON:

composer.sequence(
    composer.literal(datetime.datetime.now()),
    composer.action('log', { action: lambda env, params: { 'message': 'Composition time: ' + params['value'] } }))

JSON values cannot represent functions. Applying composer.literal to a value of type 'function' will result in an error. Functions embedded in a value of type 'object', e.g., { 'f': lambda p:p, 'n': 42 } will be silently omitted from the JSON dictionary. In other words, composer.literal({ 'f': lambda p:p, 'n': 42 }) will output { 'n': 42 }.

In general, a function can be embedded in a composition either by using the composer.function combinator, or by embedding the source code for the function as a string and later using eval to evaluate the function code.

Composition

composition(name, composition) returns a composition consisting of the invocation of the composition named name and of the declaration of the composition named name defined to be composition.

composer.when('isEven', 'half', composer.composition('tripleAndIncrement', composer.sequence('triple', 'increment')))

In this example, the composer.sequence('triple', 'increment') composition is given the name tripleAndIncrement and the enclosing composition references the tripleAndIncrement composition by name. In particular, deploying this composition actually deploys two compositions:

  • a composition named tripleAndIncrement defined as composer.sequence('triple', 'increment'), and
  • a composition defined as composer.when('isEven', 'half', 'tripleAndIncrement') whose name will be specified as deployment time.

Importantly, the behavior of the second composition would be altered if we redefine the tripleAndIncrement composition to do something else, since it refers to the composition by name.

Empty

composer.empty() is a shorthand for the empty sequence composer.sequence(). It is typically used to make it clear that a composition, e.g., a branch of an when combinator, is intentionally doing nothing.

Sequence

composer.sequence(composition_1, composition_2, ...) chains a series of compositions (possibly empty).

The input parameter object for the composition is the input parameter object of the first composition in the sequence. The output parameter object of one composition in the sequence is the input parameter object for the next composition in the sequence. The output parameter object of the last composition in the sequence is the output parameter object for the composition.

If one of the components fails (i.e., returns an error object), the remainder of the sequence is not executed. The output parameter object for the composition is the error object produced by the failed component.

An empty sequence behaves as a sequence with a single lambda lambda params: params. The output parameter object for the empty sequence is its input parameter object unless it is an error object, in which case, as usual, the error object only contains the error field of the input parameter object.

Let

composer.let({ name_1: value_1, name_2: value_2, ... }, composition_1_, _composition_2_, ...) declares one or more variables with the given names and initial values, and runs a sequence of compositions in the scope of these declarations.

The initial values must be valid JSON values. In particular, composer.let({ 'foo': lambda x:x }) is incorrect as lambda x:x is not representable by a JSON value. On the other hand, composer.let({ 'foo': None }) is correct.

Variables declared with composer.let may be accessed and mutated by functions running as part of the following sequence (irrespective of their place of definition). In other words, name resolution is dynamic. If a variable declaration is nested inside a declaration of a variable with the same name, the innermost declaration masks the earlier declarations.

For example, the following composition invokes composition composition repeatedly n times.

def dec(args):
  return args['i']-- > 0

composer.let({ 'i': n }, composer.loop(dec, composition))

Variables declared with composer.let are not visible to invoked actions. However, they may be passed as parameters to actions as for instance in:

def assign_n(env, params):
    env['n'] = params['n']

composer.let({ 'n': 42 }, lambda env, params: { 'n': env['n'] }, 'increment', assign_n)

In this example, the variable n is exposed to the invoked action as a field of the input environment object. Moreover, the value of the field n of the output environment object is assigned back to variable n.

Mask

composer.mask(composition_1, composition_2, ..) is meant to be used in combination with the let combinator. It makes it possible to hide the innermost enclosing let combinator from composition. It is typically used to define composition templates that need to introduce variables.

For instance, the following function is a possible implementation of a repeat loop:

def dec_n_positive(env, params):
    env['n'] -= 1
    return env['n'] > 0

def loop(n, composition) {
    return composer.let({ 'n': n }, composer.loop(dec_n_positive, composer.mask(composition)))
}

This function takes two parameters: the number of iterations n and the composition to repeat n times. Here, the mask combinator makes sure that this declaration of n is not visible to composition. Thanks to mask, the following example correctly returns { value: 12 }.

def inc_n(env, params):
    env['n'] += 1

composer.let({ n: 0 }, loop(3, loop(4, inc_n)))

While composer variables are dynamically scoped, the mask combinator alleviates the biggest concern with dynamic scoping: incidental name collision.

When

composer.when(condition, consequent, [alternate]) runs either the consequent composition if the condition evaluates to true or the alternate composition if not.

A condition composition evaluates to true if and only if it produces a JSON dictionary with a field value with value true. Other fields are ignored. Because JSON values other than dictionaries are implicitly lifted to dictionaries with a value field, condition may be a Python function returning a Boolean value. An expression such as params.n > 0 is not a valid condition (or in general a valid composition). One should write instead params => params.n > 0. The input parameter object for the composition is the input parameter object for the condition composition.

The alternate composition may be omitted. If condition fails, neither branch is executed.

The output parameter object of the condition composition is discarded, one the choice of a branch has been made and the consequent composition or alternate composition is invoked on the input parameter object for the composition. For example, the following composition divides parameter n by two if n is even:

def divide_2(env, params):
    env['n'] /= 2

composer.when(lambda env, params: env['n'] % 2 == 0, divide_2)

The when_nosave combinator is similar but it does not preserve the input parameter object, i.e., the consequent composition or alternate composition is invoked on the output parameter object of condition. The following example also divides parameter n by two if n is even:

def divide_2(env, params):
    env['n'] /= 2

def assign_value(env, params):
    params['value'] = env['n'] % 2 == 0


composer.when_nosave(assign_value, divide_2)

In the first example, the condition function simply returns a Boolean value. The consequent function uses the saved input parameter object to compute n's value. In the second example, the condition function adds a value field to the input parameter object. The consequent function applies to the resulting object. In particular, in the second example, the output parameter object for the condition includes the value field.

While, the when combinator is typically more convenient, preserving the input parameter object is not free as it counts toward the parameter size limit for OpenWhisk actions. In essence, the limit on the size of parameter objects processed during the evaluation of the condition is reduced by the size of the saved parameter object. The when_nosave combinator omits the parameter save, hence preserving the parameter size limit.

Loop

composer.loop(condition, body) runs body repeatedly while condition evaluates to true. The condition composition is evaluated before any execution of the body composition. See composer.when for a discussion of conditions.

A failure of condition or body interrupts the execution. The composition returns the error object from the failed component.

The output parameter object of the condition composition is discarded and the input parameter object for the body composition is either the input parameter object for the whole composition the first time around or the output parameter object of the previous iteration of body. However, if loop_nosave combinator is used, the input parameter object for body is the output parameter object of condition. Moreover, the output parameter object for the whole composition is the output parameter object of the last condition evaluation.

For instance, the following composition invoked on dictionary { n: 28 } returns { n: 7 }:

composer.loop(params => params.n % 2 === 0, params => { params.n /= 2 }) # TODO

For instance, the following composition invoked on dictionary { n: 28 } returns { n: 7, value: false }:

composer.loop_nosave(params => { params.value = params.n % 2 === 0 }, params => { params.n /= 2 }) # TODO

Dowhile

composer.dowhile(condition, body) is similar to composer.while(body, condition) except that body is invoked before condition is evaluated, hence body is always invoked at least once.

Like while_nosave, dowhile_nosave does not implicitly preserve the parameter object while evaluating condition.

Repeat

composer.repeat(count, body) invokes body count times.

Do

composer.do(body, handler) runs body with error handler handler.

If body returns an error object, handler is invoked with this error object as its input parameter object. Otherwise, handler is not run.

Ensure

composer.ensure(body, finalizer) runs body and then finalizer.

The finalizer is invoked in sequence after body even if body returns an error object.

Retry

composer.retry(count, body) runs body and retries body up to count times if it fails. The output parameter object for the composition is either the output parameter object of the successful body invocation or the error object produced by the last body invocation.

Retain

composer.retain(body) runs body on the input parameter object producing an object with two fields params and result such that params is the input parameter object of the composition and result is the output parameter object of body.

If body fails, the output of the retain combinator is only the error object (i.e., the input parameter object is not preserved). In contrast, the retain_catch combinator always outputs { params, result }, even if result is an error result.

Merge

composer.merge(composition_1, composition_2, ...) runs a sequence of compositions on the input parameter object and merge the output parameter object of the sequence into the input parameter object. In other words, composer.merge(composition_1, composition_2, ...) is a shorthand for:

def extends(dict1, dict2):
    dict1.extends(dict2)
    return dict1

composer.seq(composer.retain(composition_1, composition_2, ...), lambda _, args: extends(args['params'], args['result']))

Asynchronous

composer.asynchronous(composition_1, composition_2, ...) runs a sequence of compositions asynchronously. It invokes the sequence but does not wait for it to execute. It immediately returns a dictionary that includes a field named activationId with the activation id for the sequence invocation.