openwhisk-commits mailing list archives

Site index · List index
Message view « Date » · « Thread »
Top « Date » · « Thread »
From dgr...@apache.org
Subject [incubator-openwhisk-composer] branch master updated: Add parallel, map, and dynamic combinators (#16)
Date Sat, 19 Jan 2019 22:06:54 GMT
This is an automated email from the ASF dual-hosted git repository.

dgrove pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-openwhisk-composer.git


The following commit(s) were added to refs/heads/master by this push:
     new c58ed88  Add parallel, map, and dynamic combinators (#16)
c58ed88 is described below

commit c58ed88f368aa0a97b06838ac3063bf7a98f2257
Author: Olivier Tardieu <tardieu@users.noreply.github.com>
AuthorDate: Sat Jan 19 17:06:50 2019 -0500

    Add parallel, map, and dynamic combinators (#16)
    
    * Add parallel, map, and dynamic combinators
---
 .travis.yml          |   1 +
 README.md            |  55 ++++++++++++++++---
 composer.js          |   8 ++-
 conductor.js         | 150 ++++++++++++++++++++++++++++++++++++++++++++++++++-
 docs/COMBINATORS.md  |  70 ++++++++++++++++++++++++
 docs/COMPOSITIONS.md |   8 +--
 test/composer.js     |  16 ++++++
 test/conductor.js    |  45 ++++++++++++++++
 travis/setup.sh      |   3 ++
 9 files changed, 344 insertions(+), 12 deletions(-)

diff --git a/.travis.yml b/.travis.yml
index 5ec856f..051e253 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -23,6 +23,7 @@ services:
 env:
   global:
     - IGNORE_CERTS=true
+    - REDIS=redis://172.17.0.1:6379
 before_install:
   - ./travis/scancode.sh
 before_script:
diff --git a/README.md b/README.md
index c3554aa..9fa0af1 100644
--- a/README.md
+++ b/README.md
@@ -65,12 +65,12 @@ module.exports = composer.if(
     composer.action('failure', { action: function () { return { message: 'failure' } } }))
 ```
 Compositions compose actions using [combinator](docs/COMBINATORS.md) methods.
-These methods implement the typical control-flow constructs of a sequential
-imperative programming language. This example composition composes three actions
-named `authenticate`, `success`, and `failure` using the `composer.if`
-combinator, which implements the usual conditional construct. It take three
-actions (or compositions) as parameters. It invokes the first one and, depending
-on the result of this invocation, invokes either the second or third action.
+These methods implement the typical control-flow constructs of an imperative
+programming language. This example composition composes three actions named
+`authenticate`, `success`, and `failure` using the `composer.if` combinator,
+which implements the usual conditional construct. It take three actions (or
+compositions) as parameters. It invokes the first one and, depending on the
+result of this invocation, invokes either the second or third action.
 
  This composition includes the definitions of the three composed actions. If the
  actions are defined and deployed elsewhere, the composition code can be shorten
@@ -143,6 +143,49 @@ Compositions are implemented by means of OpenWhisk conductor actions.
The
 actions](https://github.com/apache/incubator-openwhisk/blob/master/docs/conductors.md)
 explains execution traces in greater details.
 
+While composer does not limit in principle the length of a composition,
+OpenWhisk deployments typically enforce a limit on the number of action
+invocations in a composition as well as an upper bound on the rate of
+invocation. These limits may result in compositions failing to execute to
+completion.
+
+## Parallel compositions with Redis
+
+Composer offers parallel combinators that make it possible to run actions or
+compositions in parallel, for example:
+```javascript
+composer.parallel('checkInventory', 'detectFraud')
+```
+
+The width of parallel compositions is not in principle limited by composer, but
+issuing many concurrent invocations may hit OpenWhisk limits leading to
+failures: failure to execute a branch of a parallel composition or failure to
+complete the parallel composition.
+
+These combinators require access to a Redis instance to hold intermediate
+results of parallel compositions. The Redis credentials may be specified at
+invocation time or earlier by means of default parameters or package bindings.
+The required parameter is named `$composer`. It is a dictionary with a `redis`
+field of type dictionary. The `redis` dictionary specifies the `uri` for the
+Redis instance and optionally a certificate as a base64-encoded string to enable
+tls connections. Hence, the input parameter object for our order-processing
+example should be:
+```json
+{
+    "$composer": {
+        "redis": {
+            "uri": "redis://...",
+            "ca": "optional base64 encoded tls certificate"
+        }
+    },
+    "order": { ... }
+}
+```
+
+The intent is to store intermediate results in Redis as the parallel composition
+is progressing. Redis entries are deleted after completion and, as an added
+safety, expire after twenty-four hours.
+
 # Disclaimer
 
 Apache OpenWhisk Composer is an effort undergoing incubation at The Apache Software Foundation
(ASF), sponsored by the Apache Incubator. Incubation is required of all newly accepted projects
until a further review indicates that the infrastructure, communications, and decision making
process have stabilized in a manner consistent with other successful ASF projects. While incubation
status is not necessarily a reflection of the completeness or stability of the code, it does
indicate that  [...]
diff --git a/composer.js b/composer.js
index ed3ef97..66f500f 100644
--- a/composer.js
+++ b/composer.js
@@ -285,7 +285,10 @@ const combinators = {
   mask: { components: true },
   action: { args: [{ name: 'name', type: 'name' }, { name: 'action', type: 'object', optional:
true }] },
   function: { args: [{ name: 'function', type: 'object' }] },
-  async: { components: true }
+  async: { components: true },
+  parallel: { components: true },
+  map: { components: true },
+  dynamic: {}
 }
 
 Object.assign(composer, declare(combinators))
@@ -303,7 +306,8 @@ const extra = {
   retain_catch: { components: true, def: lowerer.retain_catch },
   value: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal },
   literal: { args: [{ name: 'value', type: 'value' }], def: lowerer.literal },
-  merge: { components: true, def: lowerer.merge }
+  merge: { components: true, def: lowerer.merge },
+  par: { components: true, def: composer.parallel }
 }
 
 Object.assign(composer, declare(extra))
diff --git a/conductor.js b/conductor.js
index aca9fb1..ac595a4 100644
--- a/conductor.js
+++ b/conductor.js
@@ -83,10 +83,79 @@ class Compositions {
 // runtime code
 function main (composition) {
   const openwhisk = require('openwhisk')
+  const redis = require('redis')
+  const uuid = require('uuid').v4
   let wsk
+  let db
+  const expiration = 86400 // expire redis key after a day
+
+  function live (id) { return `composer/fork/${id}` }
+  function done (id) { return `composer/join/${id}` }
+
+  function createRedisClient (p) {
+    const client = redis.createClient(p.s.redis.uri, p.s.redis.ca ? { tls: { ca: Buffer.from(p.s.redis.ca,
'base64').toString('binary') } } : {})
+    const noop = () => { }
+    let handler = noop
+    client.on('error', error => handler(error))
+    require('redis-commands').list.forEach(f => {
+      client[`${f}Async`] = function () {
+        let failed = false
+        return new Promise((resolve, reject) => {
+          handler = error => {
+            handler = noop
+            failed = true
+            reject(error)
+          }
+          client[f](...arguments, (error, result) => {
+            handler = noop
+            return error ? reject(error) : resolve(result)
+          })
+        }).catch(error => {
+          if (failed) client.end(true)
+          return Promise.reject(error)
+        })
+      }
+    })
+    return client
+  }
 
   const isObject = obj => typeof obj === 'object' && obj !== null && !Array.isArray(obj)
 
+  function fork ({ p, node, index }, array, it) {
+    const saved = p.params // save params
+    p.s.state = index + node.return // return state
+    p.params = { value: [] } // return value
+    if (array.length === 0) return
+    if (typeof p.s.redis !== 'object' || typeof p.s.redis.uri !== 'string' || (typeof p.s.redis.ca
!== 'string' && typeof p.s.redis.ca !== 'undefined')) {
+      p.params = { error: 'Parallel combinator requires a properly configured redis instance'
}
+      console.error(p.params.error)
+      return
+    }
+    const stack = [{ marker: true }].concat(p.s.stack)
+    const barrierId = uuid()
+    console.log(`barrierId: ${barrierId}, spawning: ${array.length}`)
+    if (!wsk) wsk = openwhisk({ ignore_certs: true })
+    if (!db) db = createRedisClient(p)
+    return db.lpushAsync(live(barrierId), 42) // push marker
+      .then(() => db.expireAsync(live(barrierId), expiration))
+      .then(() => Promise.all(array.map((item, position) => {
+        const params = it(saved, item) // obtain combinator-specific params for branch invocation
+        params.$composer.stack = stack
+        params.$composer.redis = p.s.redis
+        params.$composer.join = { barrierId, position, count: array.length }
+        return wsk.actions.invoke({ name: process.env.__OW_ACTION_NAME, params }) // invoke
branch
+          .then(({ activationId }) => { console.log(`barrierId: ${barrierId}, spawned
position: ${position} with activationId: ${activationId}`) })
+      }))).then(() => collect(p, barrierId), error => {
+        console.error(error.body || error)
+        p.params = { error: `Parallel combinator failed to invoke a composition at AST node
root${node.parent} (see log for details)` }
+        return db.delAsync(live(barrierId), done(barrierId)) // delete keys
+          .then(() => {
+            inspect(p)
+            return step(p)
+          })
+      })
+  }
+
   // compile ast to fsm
   const compiler = {
     sequence (parent, node) {
@@ -148,6 +217,23 @@ function main (composition) {
       const fsm = [{ parent, type: 'pass' }, ...compile(parent, node.body), ...compile(parent,
node.test), { parent, type: 'choice', else: 1 }, { parent, type: 'pass' }]
       fsm[fsm.length - 2].then = 2 - fsm.length
       return fsm
+    },
+
+    parallel (parent, node) {
+      const tasks = node.components.map(task => [...compile(parent, task), { parent, type:
'stop' }])
+      const fsm = [{ parent, type: 'parallel' }, ...tasks.reduce((acc, cur) => { acc.push(...cur);
return acc }, []), { parent, type: 'pass' }]
+      fsm[0].return = fsm.length - 1
+      fsm[0].tasks = tasks.reduce((acc, cur) => { acc.push(acc[acc.length - 1] + cur.length);
return acc }, [1]).slice(0, -1)
+      return fsm
+    },
+
+    map (parent, node) {
+      const tasks = compile(parent, ...node.components)
+      return [{ parent, type: 'map', return: tasks.length + 2 }, ...tasks, { parent, type:
'stop' }, { parent, type: 'pass' }]
+    },
+
+    dynamic (parent, node) {
+      return [{ parent, type: 'dynamic' }]
     }
   }
 
@@ -208,7 +294,7 @@ function main (composition) {
     },
 
     async ({ p, node, index, inspect, step }) {
-      p.params.$composer = { state: p.s.state, stack: [{ marker: true }].concat(p.s.stack)
}
+      p.params.$composer = { state: p.s.state, stack: [{ marker: true }].concat(p.s.stack),
redis: p.s.redis }
       p.s.state = index + node.return
       if (!wsk) wsk = openwhisk({ ignore_certs: true })
       return wsk.actions.invoke({ name: process.env.__OW_ACTION_NAME, params: p.params })
@@ -225,6 +311,31 @@ function main (composition) {
 
     stop ({ p, node, index, inspect, step }) {
       p.s.state = -1
+    },
+
+    parallel ({ p, node, index }) {
+      return fork({ p, node, index }, node.tasks, (input, branch) => {
+        const params = Object.assign({}, input) // clone
+        params.$composer = { state: index + branch }
+        return params
+      })
+    },
+
+    map ({ p, node, index }) {
+      return fork({ p, node, index }, p.params.value || [], (input, branch) => {
+        const params = isObject(branch) ? branch : { value: branch } // wrap
+        params.$composer = { state: index + 1 }
+        return params
+      })
+    },
+
+    dynamic ({ p, node, index }) {
+      if (p.params.type !== 'action' || typeof p.params.name !== 'string' || typeof p.params.params
!== 'object') {
+        p.params = { error: `Incorrect use of the dynamic combinator at AST node root${node.parent}`
}
+        inspect(p)
+      } else {
+        return { method: 'action', action: p.params.name, params: p.params.params, state:
{ $composer: p.s } }
+      }
     }
   }
 
@@ -232,6 +343,29 @@ function main (composition) {
     return p.params.error ? p.params : { params: p.params }
   }
 
+  function collect (p, barrierId) {
+    if (!db) db = createRedisClient(p)
+    const timeout = Math.max(Math.floor((process.env.__OW_DEADLINE - new Date()) / 1000)
- 5, 1)
+    console.log(`barrierId: ${barrierId}, waiting with timeout: ${timeout}s`)
+    return db.brpopAsync(done(barrierId), timeout) // pop marker
+      .then(marker => {
+        console.log(`barrierId: ${barrierId}, done waiting`)
+        if (marker !== null) {
+          return db.lrangeAsync(done(barrierId), 0, -1)
+            .then(result => result.map(JSON.parse).map(({ position, params }) => {
p.params.value[position] = params }))
+            .then(() => db.delAsync(live(barrierId), done(barrierId))) // delete keys
+            .then(() => {
+              inspect(p)
+              return step(p)
+            })
+        } else { // timeout
+          p.s.collect = barrierId
+          console.log(`barrierId: ${barrierId}, handling timeout`)
+          return { method: 'action', action: '/whisk.system/utils/echo', params: p.params,
state: { $composer: p.s } }
+        }
+      })
+  }
+
   const internalError = error => Promise.reject(error) // terminate composition execution
and record error
 
   // wrap params if not a dictionary, branch to error handler if error
@@ -288,6 +422,14 @@ function main (composition) {
     if (p.s.state < 0 || p.s.state >= fsm.length) {
       console.log(`Entering final state`)
       console.log(JSON.stringify(p.params))
+      if (p.s.join) {
+        if (!db) db = createRedisClient(p)
+        return db.lpushxAsync(live(p.s.join.barrierId), JSON.stringify({ position: p.s.join.position,
params: p.params })).then(count => { // push only if marker is present
+          return (count > p.s.join.count ? db.renameAsync(live(p.s.join.barrierId), done(p.s.join.barrierId))
: Promise.resolve())
+        }).then(() => {
+          p.params = { method: 'join', sessionId: p.s.session, barrierId: p.s.join.barrierId,
position: p.s.join.position }
+        })
+      }
       return
     }
 
@@ -315,6 +457,12 @@ function main (composition) {
       if (typeof p.s.state !== 'number') return internalError('state parameter is not a number')
       if (!Array.isArray(p.s.stack)) return internalError('stack parameter is not an array')
 
+      if (p.s.collect) { // waiting on parallel branches
+        const barrierId = p.s.collect
+        delete p.s.collect
+        return collect(p, barrierId)
+      }
+
       if ($composer.resuming) inspect(p) // handle error objects when resuming
 
       return step(p)
diff --git a/docs/COMBINATORS.md b/docs/COMBINATORS.md
index 28540e9..d3d9cd2 100644
--- a/docs/COMBINATORS.md
+++ b/docs/COMBINATORS.md
@@ -26,14 +26,17 @@ The `composer` module offers a number of combinators to define compositions:
 | [`action`](#action) | named action | `composer.action('echo')` |
 | [`async`](#async) | asynchronous invocation | `composer.async('compress', 'upload')` |
 | [`dowhile` and `dowhile_nosave`](#dowhile) | loop at least once | `composer.dowhile('fetchData',
'needMoreData')` |
+| [`dynamic`](#dynamic) | dynamic invocation | `composer.dynamic()`
 | [`empty`](#empty) | empty sequence | `composer.empty()`
 | [`finally`](#finally) | finalization | `composer.finally('tryThis', 'doThatAlways')` |
 | [`function`](#function) | Javascript function | `composer.function(({ x, y }) => ({
product: x * y }))` |
 | [`if` and `if_nosave`](#if) | conditional | `composer.if('authenticate', 'success', 'failure')`
|
 | [`let`](#let) | variable declarations | `composer.let({ count: 3, message: 'hello' }, ...)`
|
 | [`literal` or `value`](#literal) | constant value | `composer.literal({ message: 'Hello,
World!' })` |
+| [`map`](#map) | parallel map | `composer.map('validate', 'compute')` |
 | [`mask`](#mask) | variable hiding | `composer.let({ n }, composer.while(_ => n-- >
0, composer.mask(composition)))` |
 | [`merge`](#merge) | data augmentation | `composer.merge('hash')` |
+| [`parallel` or `par`](#parallel) | parallel composition | `composer.parallel('compress',
'hash')` |
 | [`repeat`](#repeat) | counted loop | `composer.repeat(3, 'hello')` |
 | [`retain` and `retain_catch`](#retain) | persistence | `composer.retain('validateInput')`
|
 | [`retry`](#retry) | error recovery | `composer.retry(3, 'connect')` |
@@ -425,3 +428,70 @@ composer.seq(composer.retain(composition_1, composition_2, ...), ({ params,
resu
 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.
+
+The spawned sequence operates on a copy of the execution context for the parent
+composition. Variables declared in the parent are defined for the child and are
+initialized with the parent values at the time of the `async`. But mutations or
+later declarations in the parent are not visible in the child and vice versa.
+
+## Parallel
+
+Parallel combinators require access to a Redis instance as discussed
+[here](../README.md#parallel-compositions-with-redis).
+
+`composer.parallel(composition_1, composition_2, ...)` and its synonymous
+`composer.par(composition_1, composition_2, ...)` invoke a series of
+compositions (possibly empty) in parallel.
+
+This combinator runs _composition_1_, _composition_2_, ... in parallel and waits
+for all of these compositions to complete.
+
+The input parameter object for the composition is the input parameter object for
+every branch in the composition. The output parameter object for the composition
+has a single field named `value` of type array. The elements of the array are
+the output parameter objects for the branches in order.
+
+The `composer.let` variables in scope at the `parallel` combinator are in scope
+in the branches. But each branch has its own copy of the execution context.
+Variable mutations in one branch are not reflected in other branches or in the
+parent composition.
+
+## Map
+
+Parallel combinators require access to a Redis instance as discussed
+[here](../README.md#parallel-compositions-with-redis).
+
+`composer.map(composition_1, composition_2, ...)` makes multiple parallel
+invocations of a sequence of compositions.
+
+The input parameter object for the `map` combinator should include an array of
+named _value_. The `map` combinator spawns one sequence for each element of this
+array. The input parameter object for the nth instance of the sequence is the
+nth array element if it is a dictionary or an object with a single field named
+`value` with the nth array element as the field value. Fields on the input
+parameter object other than the `value` field are discarded. These sequences run
+in parallel. The `map` combinator waits for all the sequences to complete. The
+output parameter object for the composition has a single field named `value` of
+type array. The elements of the array are the output parameter objects for the
+branches in order.
+
+The `composer.let` variables in scope at the `map` combinator are in scope in
+the branches. But each branch has its own copy of the execution context.
+Variable mutations in one branch are not reflected in other branches or in the
+parent composition.
+
+## Dynamic
+
+`composer.dynamic()` invokes an action specified by means of the input parameter
+object.
+
+The input parameter object for the `dynamic` combinator must be a dictionary
+including the following three fields:
+- a field `type` with string value `"action"`,
+- a field `name` of type string,
+- a field `params` of type dictionary.
+Other fields of the input parameter object are ignored.
+
+The `dynamic` combinator invokes the action named _name_ with the input
+parameter object _params_. The output parameter object for the composition is
+the output parameter object of the action invocation.
diff --git a/docs/COMPOSITIONS.md b/docs/COMPOSITIONS.md
index 045d248..342b4f6 100644
--- a/docs/COMPOSITIONS.md
+++ b/docs/COMPOSITIONS.md
@@ -25,13 +25,15 @@ _compositions_. An example composition is described in
 
 ## Control flow
 
-Compositions can express the control flow of typical a sequential imperative
-programming language: sequences, conditionals, loops, structured error handling.
-This control flow is specified using _combinator_ methods such as:
+Compositions can express the control flow of typical imperative programming
+language: sequences, conditionals, loops, structured error handling. This
+control flow is specified using _combinator_ methods such as:
 - `composer.sequence(firstAction, secondAction)`
 - `composer.if(conditionAction, consequentAction, alternateAction)`
 - `composer.try(bodyAction, handlerAction)`
 
+Parallel constructs are also available.
+
 Combinators are described in [COMBINATORS.md](COMBINATORS.md).
 
 ## Composition objects
diff --git a/test/composer.js b/test/composer.js
index eca428d..ce0fc0c 100644
--- a/test/composer.js
+++ b/test/composer.js
@@ -414,4 +414,20 @@ describe('composer', function () {
   describe('composer.merge', function () {
     check('merge')
   })
+
+  describe('composer.parallel', function () {
+    check('parallel')
+  })
+
+  describe('composer.par', function () {
+    check('par')
+  })
+
+  describe('composer.map', function () {
+    check('map')
+  })
+
+  describe('composer.dynamic', function () {
+    check('dynamic', 0)
+  })
 })
diff --git a/test/conductor.js b/test/conductor.js
index da3324d..90b936e 100644
--- a/test/conductor.js
+++ b/test/conductor.js
@@ -33,12 +33,17 @@ const invoke = (composition, params = {}, blocking = true) => wsk.compositions.d
   .then(() => wsk.actions.invoke({ name, params, blocking }))
   .then(activation => activation.response.success ? activation : Promise.reject(Object.assign(new
Error(), { error: activation })))
 
+// redis configuration
+const redis = process.env.REDIS ? { uri: process.env.REDIS } : false
+if (process.env.REDIS && process.env.REDIS_CA) redis.ca = process.env.REDIS_CA
+
 describe('composer', function () {
   let n, x, y // dummy variables
 
   this.timeout(60000)
 
   before('deploy test actions', function () {
+    if (!redis) console.error('------------------------------------------------\nMissing
redis configuration, skipping some tests\n------------------------------------------------')
     return define({ name: 'echo', action: 'const main = x=>x' })
       .then(() => define({ name: 'DivideByTwo', action: 'function main({n}) { return {
n: n / 2 } }' }))
       .then(() => define({ name: 'TripleAndIncrement', action: 'function main({n}) { return
{ n: n * 3 + 1 } }' }))
@@ -114,6 +119,28 @@ describe('composer', function () {
       })
     })
 
+    describe('dynamic', function () {
+      it('dynamic action invocation', function () {
+        return invoke(composer.dynamic(), { type: 'action', name: 'DivideByTwo', params:
{ n: 42 } }).then(activation => assert.deepStrictEqual(activation.response.result, { n:
21 }))
+      })
+
+      it('missing type', function () {
+        return invoke(composer.dynamic(), { name: 'DivideByTwo', params: { n: 42 } }).then(()
=> assert.fail(), activation => assert.ok(activation.error.response.result.error))
+      })
+
+      it('invalid type', function () {
+        return invoke(composer.dynamic(), { type: 42, name: 'DivideByTwo', params: { n: 42
} }).then(() => assert.fail(), activation => assert.ok(activation.error.response.result.error))
+      })
+
+      it('missing name', function () {
+        return invoke(composer.dynamic(), { type: 'action', params: { n: 42 } }).then(()
=> assert.fail(), activation => assert.ok(activation.error.response.result.error))
+      })
+
+      it('missing params', function () {
+        return invoke(composer.dynamic(), { type: 'action', name: 'DivideByTwo' }).then(()
=> assert.fail(), activation => assert.ok(activation.error.response.result.error))
+      })
+    })
+
     describe('literals', function () {
       it('true', function () {
         return invoke(composer.literal(true)).then(activation => assert.deepStrictEqual(activation.response.result,
{ value: true }))
@@ -291,6 +318,24 @@ describe('composer', function () {
         })
       })
 
+      describe('parallel', function () {
+        const test = redis ? it : it.skip
+        test('parallel', function () {
+          return invoke(composer.parallel('TripleAndIncrement', 'DivideByTwo'), { n: 42,
$composer: { redis } })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value:
[{ n: 127 }, { n: 21 }] }))
+        })
+
+        test('par', function () {
+          return invoke(composer.par('DivideByTwo', 'TripleAndIncrement', 'isEven'), { n:
42, $composer: { redis } })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value:
[{ n: 21 }, { n: 127 }, { value: true }] }))
+        })
+
+        test('map', function () {
+          return invoke(composer.map('TripleAndIncrement', 'DivideByTwo'), { value: [{ n:
3 }, { n: 5 }, { n: 7 }], $composer: { redis } })
+            .then(activation => assert.deepStrictEqual(activation.response.result, { value:
[{ n: 5 }, { n: 8 }, { n: 11 }] }))
+        })
+      })
+
       describe('if', function () {
         it('condition = true', function () {
           return invoke(composer.if('isEven', 'DivideByTwo', 'TripleAndIncrement'), { n:
4 })
diff --git a/travis/setup.sh b/travis/setup.sh
index e7bdd1b..82ebf3f 100755
--- a/travis/setup.sh
+++ b/travis/setup.sh
@@ -50,6 +50,9 @@ $ANSIBLE_CMD initdb.yml
 $ANSIBLE_CMD wipe.yml
 $ANSIBLE_CMD openwhisk.yml -e cli_installation_mode=remote -e limit_invocations_per_minute=600
 
+# Deploy Redis
+docker run -d -p 6379:6379 --name redis redis:4.0
+
 # Log configuration
 docker images
 docker ps


Mime
View raw message