guv
Version:
Grid Utilization Virgilante
223 lines (177 loc) • 6.21 kB
text/coffeescript
# guv - Scaling governor of cloud workers
# (c) 2015 The Grid
# guv may be freely distributed under the MIT license
debug = require('debug')('guv:config')
gaussian = require 'gaussian'
url = require 'url'
yaml = require 'js-yaml'
fs = require 'fs'
path = require 'path'
schemas =
roleconfig: require '../schema/roleconfig.json'
config: require '../schema/config.json'
calculateTarget = (config) ->
# Calculate the point which the process completes
# the desired percentage of jobs within
debug 'calculate target for', config.processing, config.stddev, config.deadline
tolerance = (100-config.percentile)/100
mean = config.processing
variance = config.stddev*config.stddev
d = gaussian mean, variance
ppf = -d.ppf(tolerance)
distance = mean+ppf
# Shift the point up till hits at the specified deadline
# XXX: Is it a safe assumption that variance is same for all
target = config.deadline-distance
return target
jobsInDeadline = (config) ->
return config.target / config.process_time
# Syntactical part
parse = (str) ->
o = yaml.safeLoad str
o = {} if not o
return o
serialize = (parsed) ->
return yaml.safeDump parsed
clone = (obj) ->
return JSON.parse JSON.stringify obj
configFormat = () ->
format =
shortoptions: {}
options: {}
for name, value of schemas.roleconfig.properties
o = clone value
throw new Error "Missing type for config property #{name}" if not o.type
o.name = name
format.options[name] = o
format.shortoptions[o.shorthand] = o if o.shorthand
return format
addDefaults = (format, role, c) ->
for name, option of format.options
continue if typeof option.default == 'string'
c[name] = option.default if not c[name]?
# TODO: have a way of declaring these functions in JSON schema?
c.statuspage = process.env['STATUSPAGE_ID'] if not c.statuspage
c.broker = process.env['GUV_BROKER'] if not c.broker
c.app = process.env['GUV_APP'] if not c.app
c.broker = process.env['CLOUDAMQP_URL'] if not c.broker
c.errors = [] # avoid shared ref
if role != '*'
c.worker = role if not c.worker
c.queue = role if not c.queue
c.stddev = c.processing*0.5 if not c.stddev
c.target = calculateTarget c if not c.target
if c.target <= c.processing
e = new Error "Target #{c.target.toFixed(2)}s is lower than processing time #{c.processing.toFixed(2)}s. Attempted deadline #{c.deadline}s."
debug 'target error', e
c.errors.push e # for later reporting
c.target = c.processing+0.01 # do our best
return c
normalize = (role, vars, globals) ->
format = configFormat()
retvars = {}
# Make all globals available on each role
# Note: some things don't make sense be different per-role, but simpler this way
for k, v of globals
retvars[k] = v
for name, val of vars
# Lookup canonical long name from short
name = format.shortoptions[name].name if format.shortoptions[name]?
# Defined var
f = format.options[name]
retvars[name] = val
# Inject defaults
retvars = addDefaults format, role, retvars
return retvars
loadConfig = (parsed) ->
config = {}
# Extract globals first, as they will be merged into individual roles
globalRole = '*'
parsed[globalRole] = {} if not parsed[globalRole]
config[globalRole] = normalize globalRole, parsed[globalRole], {}
for role, vars of parsed
continue if role == globalRole
config[role] = normalize role, vars, config[globalRole]
return config
parseConfig = (str) ->
parsed = parse str
return loadConfig parsed
validateConfigObject = (parsed, options) ->
tv4 = require 'tv4'
tv4.addSchema schemas.roleconfig.id, schemas.roleconfig
tv4.addSchema schemas.config.id, schemas.config
options.allowKeys = [] if not options.allowKeys
format = configFormat()
for role, vars of parsed
for name, val of vars
# Lookup canonical long name from short
# XXX: a bit hacky way to avoid duplicate definitions in the JSON schema
if format.shortoptions[name]?
longname = format.shortoptions[name].name
vars[longname] = val
delete vars[name]
if name in options.allowKeys
delete vars[name]
checkRecursive = false
banUnknownProperties = true
result = tv4.validateMultiple parsed, schemas.config.id, checkRecursive, banUnknownProperties
errors = []
for e in result.errors
role = e.dataPath.split('/')[1]
property = e.dataPath.split('/')[2]
err = new Error "#{e.message} for #{e.dataPath}"
err.role = role
err.property = property
delete e.stack
err.schemaError = e
errors.push err
return errors
validateConfig = (str, options) ->
parsed = parse str
return validateConfigObject parsed, options
estimateRates = (cfg) ->
rates = {}
for role, c of cfg
continue if role == '*'
rate = c.concurrency * c.maximum * (1 / c.processing)
rates[role] = rate
return rates
countMinimumWorkers = (cfg) ->
byType = {}
for role, c of cfg
continue if role == '*'
byType[c.dynosize] = 0 if not byType[c.dynosize]?
byType[c.dynosize] += c.minimum
return byType
countMaximumWorkers = (cfg) ->
byType = {}
for role, c of cfg
continue if role == '*'
byType[c.dynosize] = 0 if not byType[c.dynosize]?
byType[c.dynosize] += c.maximum
return byType
main = () ->
p = process.argv[2]
f = fs.readFileSync p, 'utf-8'
parsed = parseConfig f
rates = estimateRates parsed
for role, rate of rates
perMinute = (rate*60).toFixed(2)
padded = (" " + role).slice(-40)
console.log "#{padded}: #{perMinute} jobs/minute"
minimums = countMinimumWorkers parsed
console.log 'Workers minimum'
for type, value of minimums
console.log "\t#{type}: #{value} workers"
maximums = countMaximumWorkers parsed
console.log 'Workers maximum'
for type, value of maximums
console.log "\t#{type}: #{value} workers"
main() if not module.parent
exports.validate = validateConfig
exports.validateObject = validateConfigObject
exports.parse = parseConfig
exports.parseOnly = parse
exports.load = loadConfig
exports.serialize = serialize
exports.defaults = addDefaults