msgflo
Version:
Polyglot FBP runtime based on message queues
511 lines (440 loc) • 17.9 kB
text/coffeescript
debug = require('debug')('msgflo:coordinator')
EventEmitter = require('events').EventEmitter
fs = require 'fs'
path = require 'path'
async = require 'async'
setup = require './setup'
library = require './library'
common = require './common'
findPort = (def, type, portName) ->
ports = if type == 'inport' then def.inports else def.outports
for port in ports
return port if port.id == portName
return null
connId = (fromId, fromPort, toId, toPort) ->
return "#{fromId} #{fromPort} -> #{toPort} #{toId}"
fromConnId = (id) ->
t = id.split ' '
return [ t[0], t[1], t[4], t[3] ]
iipId = (part, port) ->
return "#{part} #{port}"
fromIipId = (id) ->
return id.split ' '
participantsByRole = (participants, role) ->
matchRole = (id) =>
part = participants[id]
return part.role == role
m = Object.keys(participants).filter matchRole
return m
# XXX: there is now a mixture of participant id and role used here
findQueue = (participants, partId, dir, portName) =>
part = participants[partId]
partIdByRole = participantsByRole(participants, partId)[0]
part = participants[partIdByRole] if not part?
throw new Error "No participant info found for '#{partId}'" if not part?
for port in part[dir]
if port.id == portName
throw new Error "Queue for #{dir} '#{portName}' missing in #{JSON.stringify(part)}" if not port.queue
return port.queue
throw new Error "No matching port found for #{dir} '#{portName}' in #{JSON.stringify(part)}"
connectionFromBinding = (participants, binding) ->
byRole = {}
for id, part of participants
byRole[part.role] = part
findNodePort = (queue, dir) ->
for role, part of byRole
for port in part[dir]
if port.queue == queue
r =
node: role
port: port.id
return r
connection =
src: findNodePort binding.src, 'outports'
tgt: findNodePort binding.tgt, 'inports'
return connection
waitForParticipant = (coordinator, role, callback) ->
existing = participantsByRole coordinator.participants, role
return callback null, coordinator.participants[existing[0]] if existing.length
onTimeout = () =>
return callback new Error "Waiting for participant #{role} timed out"
timeout = setTimeout onTimeout, coordinator.options.waitTimeout*1000
onParticipantAdded = (part) =>
if part.role == role
debug 'onParticipantAdded', part.role # FIXME: take into account multiple participants with same role
clearTimeout timeout
coordinator.removeListener 'participant-added', onParticipantAdded
return callback null
coordinator.on 'participant-added', onParticipantAdded
class Coordinator extends EventEmitter
constructor: (@broker, @options = {}) ->
@participants = {}
@connections = {} # connId -> { queue: opt String, handler: opt function }
@iips = {} # iipId -> value
@started = false
@processes = {}
libraryOptions =
configfile: @options.library
componentdir: @options.componentdir
config: @options.config
@library = new library.Library libraryOptions
@exported =
inports: {}
outports: {}
@options.waitTimeout = 40 if not @options.waitTimeout?
@graphName = null
@on 'participant', @checkParticipantConnections
clearGraph: (graphName, callback) ->
@connections = {}
@iips = {}
@graphName = graphName
# XXX: should we clear the @participants with the roles that we started?
setup.killProcesses @processes, 'SIGTERM', (err) =>
return callback err
start: (callback) ->
@library.load (err) =>
return callback err if err
@broker.connect (err) =>
debug 'connected', err
return callback err if err
@broker.subscribeParticipantChange (msg) =>
try
@handleFbpMessage msg.data
catch e
console.log 'Participant discovery failed:', e.message
@broker.ackMessage msg
@started = true
debug 'started', err, @started
return callback null
stop: (callback) ->
@clearGraph @graphName, (clearErr) =>
@broker.disconnect (err) =>
return callback clearErr if clearErr
return callback err
handleFbpMessage: (data) ->
if data.protocol == 'discovery' and data.command == 'participant'
@participantDiscovered data.payload
else
throw new Error "Unknown FBP message: #{typeof(data)} #{data?.protocol}:#{data?.command}"
participantDiscovered: (definition) ->
throw new Error "Discovery message missing .id" if not definition.id
throw new Error "Discovery message missing .component" if not definition.component
throw new Error "Discovery message missing .role" if not definition.role
throw new Error "Discovery message missing .inports" if not definition.inports
throw new Error "Discovery message missing .outports" if not definition.outports
if @participants[definition.id]
@updateParticipant definition
else
@addParticipant definition
updateParticipant: (definition) ->
debug 'updateParticipant', definition.id
original = @participants[definition.id]
definition.extra = {} if not definition.extra
definition.extra.lastSeen = new Date
newDefinition = common.clone definition
for k, v of original
newDefinition[k] = v if not newDefinition[k]
@participants[definition.id] = newDefinition
@library._updateDefinition newDefinition.component, newDefinition
@emit 'participant-updated', newDefinition
@emit 'participant', 'updated', newDefinition
addParticipant: (definition) ->
debug 'addParticipant', definition.id
definition.extra = {} if not definition.extra
definition.extra.firstSeen = new Date
definition.extra.lastSeen = new Date
@participants[definition.id] = definition
@library._updateDefinition definition.component, definition
@emit 'participant-added', definition
@emit 'participant', 'added', definition
@emit 'graph-changed'
removeParticipant: (id) ->
definition = @participants[id]
delete @participants[id]
@emit 'participant-removed', definition
@library._updateDefinition definition.component, null
@emit 'participant', 'removed', definition
@emit 'graph-changed'
addComponent: (name, language, code, callback) ->
@library.addComponent name, language, code, callback
getComponentSource: (component, callback) ->
return @library.getSource component, callback
startParticipant: (node, component, callback) ->
if not @library.components[component]?.command
console.log "WARNING: Attempting to start participant with missing component: #{node}(#{component})"
# XXX: should be an error, but Flowhub does this in project mode..
return callback null
iips = {}
cmd = @library.componentCommand component, node, iips
commands = {}
commands[node] = cmd
options =
broker: @options.broker
forward: @options.forward or ''
setup.startProcesses commands, options, (err, processes) =>
return callback err if err
for k, v of processes
@processes[k] = v
waitForParticipant @, node, (err) ->
return callback err, processes
stopParticipant: (node, component, callback) ->
processes = {}
for k, v of @processes
if k == node
processes[k] = v
setup.killProcesses processes, 'SIGTERM', (err) =>
return callback err
for k, v of processes
delete @process[k]
return callback null, processes
sendTo: (participantId, inport, message, callback) ->
debug 'sendTo', participantId, inport, message
defaultCallback = (err) ->
throw err if err
callback = defaultCallback if not callback
part = @participants[participantId]
id = participantsByRole(@participants, participantId)[0]
part = @participants[id] if not part?
port = findPort part, 'inport', inport
return callback new Error "Cannot find inport #{inport}" if not port
return @broker.sendTo 'inqueue', port.queue, message, callback
subscribeTo: (participantId, outport, handler, callback) ->
defaultCallback = (err) ->
throw err if err
callback = defaultCallback if not callback
part = @participants[participantId]
id = participantsByRole(@participants, participantId)[0]
part = @participants[id] if not part?
debug 'subscribeTo', participantId, outport
port = findPort part, 'outport', outport
ackHandler = (msg) =>
return if not @started
handler msg
@broker.ackMessage msg
return callback new Error "Could not find outport #{outport} for role #{participantId}" if not port
# Cannot subscribe directly to an outqueue, must create and bind an inqueue
readQueue = 'msgflo-export-' + Math.floor(Math.random()*999999)
@broker.createQueue 'inqueue', readQueue, (err) =>
return callback err if err
@broker.addBinding {type: 'pubsub', src: port.queue, tgt: readQueue}, (err) =>
return callback err if err
@broker.subscribeToQueue readQueue, ackHandler, (err) ->
return callback err, readQueue # caller should teardown readQueue
unsubscribeFrom: () -> # FIXME: implement
connect: (fromId, fromPort, toId, toName, callback) ->
callback = ((err) ->) if not callback
# NOTE: adding partial connection info to make checkParticipantConnections logic work
edgeId = connId fromId, fromPort, toId, toName
edge =
fromId: fromId
fromPort: fromPort
toId: toId
toName: toName
srcQueue: null
tgtQueue: null
debug 'connect', edge
@connections[edgeId] = edge
# might be that it was just added/started, not yet discovered
waitForParticipant @, fromId, (err) =>
return callback err if err
waitForParticipant @, toId, (err) =>
return callback err if err
# TODO: support roundtrip
@connections[edgeId].srcQueue = findQueue @participants, fromId, 'outports', fromPort
@connections[edgeId].tgtQueue = findQueue @participants, toId, 'inports', toName
@emit 'graph-changed'
@broker.addBinding {type: 'pubsub', src:edge.srcQueue, tgt:edge.tgtQueue}, (err) =>
return callback err
disconnect: (fromId, fromPort, toId, toPort, callback) ->
edgeId = connId fromId, fromPort, toId, toPort
edge = @connections[edgeId]
return callback new Error "Could not find connection #{edgeId}" if not edge
return callback new Error "No queues for connection #{edgeId}" if not edge.srcQueue and edge.tgtQueue
@broker.removeBinding { type: 'pubsub', src: edge.srcQueue, tgt: edge.tgtQueue }, (err) =>
return callback err if err
delete @connections[edgeId]
@emit 'graph-changed'
return callback null
checkParticipantConnections: (action, participant) ->
findConnectedPorts = (dir, srcPort) =>
conn = []
# return conn if not srcPort.queue
for id, part of @participants
for port in part[dir]
continue if not port.queue
conn.push { part: part, port: port } if port.queue == srcPort.queue
return conn
isConnected = (e) =>
[fromId, fromPort, toId, toPort] = e
id = connId fromId, fromPort, toId, toPort
return @connections[id]?
if action == 'added'
id = participant.id
# inbound
for port in participant.inports
matches = findConnectedPorts 'outports', port
for m in matches
e = [m.part.id, m.port.id, id, port.id]
@connect e[0], e[1], e[2], e[3] if not isConnected e
# outbound
for port in participant.outports
matches = findConnectedPorts 'inports', port
for m in matches
e = [id, port.id, m.part.id, m.port.id]
@connect e[0], e[1], e[2], e[3] if not isConnected e
else if action == 'removed'
null # TODO: implement
else
null # ignored
addInitial: (partId, portId, data, callback) ->
id = iipId partId, portId
@iips[id] = data
waitForParticipant @, partId, (err) =>
return callback err if err
if @started
@sendTo partId, portId, data, (err) ->
return callback err
else
return callback null
removeInitial: (partId, portId) -> # FIXME: implement
# Do we need to remove it from the queue??
exportPort: (direction, external, node, internal, callback) ->
target = if direction.indexOf("in") == 0 then @exported.inports else @exported.outports
target[external] =
role: node
port: internal
subscriber: null
queue: null
graph = null # FIXME: capture
# Wait for target node to exist
waitForParticipant @, node, (err) =>
return callback err if err
if direction.indexOf('out') == 0
handler = (msg) =>
@emit 'exported-port-data', external, msg.data, graph
@subscribeTo node, internal, handler, (err, readQueue) ->
return callback err if err
target[external].subscriber = handler
target[external].queue = readQueue
return callback null
else
return callback null
unexportPort: () -> # FIXME: implement
sendToExportedPort: (port, data, callback) ->
# FIXME lookup which node, port this corresponds to
internal = @exported.inports[port]
debug 'sendToExportedPort', port, internal
return callback new Error "Cannot find exported port #{port}" if not internal
@sendTo internal.role, internal.port, data, callback
startNetwork: (networkId, callback) ->
# Don't have a concept of started/stopped so far, no-op
setTimeout callback, 10
stopNetwork: (networkId, callback) ->
# Don't have a concept of started/stopped so far, no-op
setTimeout callback, 10
_onConnectionData: (binding, data) =>
connection = connectionFromBinding @participants, binding
connection.graph = @graphName
@emit 'connection-data', connection, data
clearSubscriptions: (callback) ->
@broker.listSubscriptions (err, subs) =>
return callback err if err
async.map subs, (sub, cb) =>
@broker.unsubscribeData sub, @_onConnectionData, cb
, callback
subscribeConnection: (fromRole, fromPort, toRole, toPort, callback) ->
waitForParticipant @, fromRole, (err) =>
return callback err if err
waitForParticipant @, toRole, (err) =>
return callback err if err
binding =
src: findQueue @participants, fromRole, 'outports', fromPort
tgt: findQueue @participants, toRole, 'inports', toPort
@broker.subscribeData binding, @_onConnectionData, callback
unsubscribeConnection: (fromRole, fromPort, toRole, toPort, callback) ->
waitForParticipant @, fromRole, (err) =>
return callback err if err
waitForParticipant @, toRole, (err) =>
return callback err if err
binding =
src: findQueue @participants, fromRole, 'outports', fromPort
tgt: findQueue @participants, toRole, 'inports', toPort
@broker.unsubscribeData binding, @_onConnectionData, callback
return callback null
serializeGraph: (name) ->
graph =
properties:
name: name
environment:
type: 'msgflo'
processes: {}
connections: []
inports: []
outports: []
participantNames = Object.keys(@participants).sort()
for id in participantNames
part = @participants[id]
name = part.role
graph.processes[name] =
component: part.component
connectionIds = Object.keys(@connections).sort()
for id in connectionIds
conn = @connections[id]
parts = fromConnId id
edge =
src:
process: parts[0]
port: parts[1]
tgt:
process: parts[2]
port: parts[3]
graph.connections.push edge
return graph
loadGraphFile: (path, opts, callback) ->
debug 'loadGraphFile', path
options =
graphfile: path
libraryfile: @library.configfile
for k, v of opts
options[k] = v
# Avoid trying to instantiate
# Probably these are external participants, which *should* be running
# TODO: check whether the participants do indeed show up
rolesWithComponent = []
rolesNoComponent = []
availableComponents = Object.keys @library.components
common.readGraph options.graphfile, (err, graph) =>
return callback err if err
for role, process of graph.processes
if process.component in availableComponents
rolesWithComponent.push role
else
rolesNoComponent.push role
if rolesNoComponent.length
console.log 'Skipping setup for participants without component available. Assuming already setup:'
for role in rolesNoComponent
componentName = graph.processes[role].component
console.log "\t#{role}(#{componentName})"
rolesToSetup = rolesWithComponent.concat([]).filter (r) ->
return r not in options.ignore
options.only = rolesToSetup
setupParticipants = (setupCallback) =>
participantStartConcurrency = 10
async.mapLimit options.only, participantStartConcurrency, (role, cb) =>
componentName = graph.processes[role].component
@startParticipant role, componentName, cb
, setupCallback
setupConnections = (setupCallback) =>
async.map graph.connections, (c, cb) =>
if c.data
@addInitial c.tgt.process, c.tgt.port, c.data, cb
else
@connect c.src.process, c.src.port, c.tgt.process, c.tgt.port, cb
, setupCallback
async.parallel
connections: setupParticipants
participants: setupConnections
, (err, results) ->
return callback err if err
return callback null
exports.Coordinator = Coordinator