├── deps.sh ├── lambda └── Lambda.go ├── test ├── .gitignore ├── package.json ├── cli.js ├── gobetween.t.sh ├── worker.js ├── client.js ├── round-robin.test.js ├── least-loaded.test.js ├── async requests │ └── asyncrequests.go ├── lambda.t.sh ├── admin.test.js ├── pkg-aware.test.js └── cluster.js ├── balancer ├── worker │ ├── Node.go │ └── WeightedNode.go ├── Balancer.go ├── roundRobin.go ├── consistentHashingBounded.go ├── util.go ├── leastLoaded.go ├── random.go └── pkgAware.go ├── config ├── olscheduler.json ├── olscheduler.d.json ├── create_balancer.go ├── Config.go └── JSONConfig.go ├── .gitignore ├── go.mod ├── thread └── Counter.go ├── Dockerfile ├── scheduler ├── } ├── Scheduler_test.go └── Scheduler.go ├── httputil ├── httpreq.go └── http.go ├── .circleci └── config.yml ├── proxy └── ReverseProxy.go ├── client └── client.go ├── go.sum ├── main.go ├── server └── server.go ├── README.md └── LICENSE /deps.sh: -------------------------------------------------------------------------------- 1 | go get github.com/urfave/cli 2 | go get github.com/lafikl/consistent 3 | -------------------------------------------------------------------------------- /lambda/Lambda.go: -------------------------------------------------------------------------------- 1 | package lambda 2 | 3 | // Lambda is a struct with info about the lambda function to run. 4 | type Lambda struct { 5 | Name string 6 | Pkgs []string 7 | } 8 | -------------------------------------------------------------------------------- /test/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | yarn-error.log 3 | 4 | # I wont add any lockfiles because I don't want to be tied to a specific 5 | # package manager. Our dependencies are small so, any package manager 6 | # should be good enough. 7 | yarn.lock 8 | -------------------------------------------------------------------------------- /balancer/worker/Node.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import "net/url" 4 | 5 | type Node struct { 6 | url url.URL 7 | Load uint 8 | } 9 | 10 | func (n *Node) GetURL() url.URL { 11 | return n.url 12 | } 13 | 14 | func NewNode(url url.URL) *Node { 15 | return &Node{url, 0} 16 | } 17 | -------------------------------------------------------------------------------- /test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "private": true, 7 | "devDependencies": { 8 | "jest": "23.6.0", 9 | "superagent": "^3.8.3" 10 | }, 11 | "scripts": { 12 | "test": "jest --env=node" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /config/olscheduler.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "port": 9080, 4 | "balancer": "pkg-aware", 5 | "load-threshold": 3, 6 | "registry": "/home/ubuntu/registry.json", 7 | "workers": [ 8 | "localhost:8080", "1", 9 | "localhost:8081", "1", 10 | "localhost:8082", "1", 11 | "localhost:8083", "1", 12 | "localhost:8084", "1" 13 | ] 14 | } -------------------------------------------------------------------------------- /config/olscheduler.d.json: -------------------------------------------------------------------------------- 1 | { 2 | "host": "localhost", 3 | "port": 9090, 4 | "balancer": "pkg-aware", 5 | "load-threshold": 3, 6 | "registry": "/home/ubuntu/registry.json", 7 | "workers": [ 8 | "localhost:8080", "1", 9 | "localhost:8081", "1", 10 | "localhost:8082", "1", 11 | "localhost:8083", "1", 12 | "localhost:8084", "1" 13 | ] 14 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.dll 4 | *.so 5 | *.dylib 6 | bin/ 7 | hack/ 8 | tmp/ 9 | 10 | # Test binary, build with `go test -c` 11 | *.test 12 | 13 | # Output of the go coverage tool, specifically when used with LiteIDE 14 | *.out 15 | 16 | # Project-local glide cache, RE: https://github.com/Masterminds/glide/issues/736 17 | .glide/ 18 | 19 | # IDE or Editor 20 | .vscode/ 21 | -------------------------------------------------------------------------------- /balancer/worker/WeightedNode.go: -------------------------------------------------------------------------------- 1 | package worker 2 | 3 | import "net/url" 4 | 5 | type WeightedNode struct { 6 | url url.URL 7 | weight int 8 | } 9 | 10 | func (n WeightedNode) GetWeight() int { 11 | return n.weight 12 | } 13 | 14 | func (n WeightedNode) GetURL() url.URL { 15 | return n.url 16 | } 17 | 18 | func NewWeightedNode(workerUrl url.URL, weight int) WeightedNode { 19 | return WeightedNode{workerUrl, weight} 20 | } 21 | -------------------------------------------------------------------------------- /balancer/Balancer.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/disel-espol/olscheduler/httputil" 8 | "github.com/disel-espol/olscheduler/lambda" 9 | ) 10 | 11 | type Balancer interface { 12 | SelectWorker(r *http.Request, l *lambda.Lambda) (url.URL, *httputil.HttpError) 13 | ReleaseWorker(workerUrl url.URL) 14 | AddWorker(workerUrl url.URL) 15 | RemoveWorker(workerUrl url.URL) 16 | GetAllWorkers() []url.URL 17 | } 18 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/disel-espol/olscheduler 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d // indirect 7 | github.com/lafikl/consistent v0.0.0-20220512074542-bdd3606bfc3e // indirect 8 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 // indirect 9 | github.com/russross/blackfriday/v2 v2.0.1 // indirect 10 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 11 | github.com/urfave/cli v1.22.9 // indirect 12 | ) 13 | -------------------------------------------------------------------------------- /thread/Counter.go: -------------------------------------------------------------------------------- 1 | package thread 2 | 3 | import "sync" 4 | 5 | type Counter struct { 6 | value uint 7 | mutex *sync.Mutex 8 | } 9 | 10 | func NewCounter() *Counter { 11 | return &Counter{0, &sync.Mutex{}} 12 | } 13 | 14 | func (c *Counter) Inc() uint { 15 | c.mutex.Lock() 16 | defer c.mutex.Unlock() 17 | 18 | currentValue := c.value 19 | c.value += 1 20 | 21 | return currentValue 22 | } 23 | 24 | func (c *Counter) Get() uint { 25 | c.mutex.Lock() 26 | defer c.mutex.Unlock() 27 | 28 | return c.value 29 | } 30 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:trusty 2 | RUN mkdir /var/log/olscheduler 3 | RUN ln -sf /dev/stdout /var/log/olscheduler/access.log 4 | RUN ln -sf /dev/stderr /var/log/olscheduler/error.log 5 | 6 | COPY bin/olscheduler /usr/bin/ 7 | 8 | CMD ["/usr/bin/olscheduler", "start", "-c", "/etc/olscheduler/conf/olscheduler.json"] 9 | 10 | LABEL org.label-schema.vendor="olscheduler" \ 11 | org.label-schema.url="https://github.com/gtotoy/olscheduler" \ 12 | org.label-schema.name="olscheduler" \ 13 | org.label-schema.description="An Extensible Scheduler for the OpenLambda FaaS Platform" 14 | -------------------------------------------------------------------------------- /test/cli.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process') 2 | const path = require('path') 3 | const { promisify } = require('util') 4 | const exec = promisify(cp.exec) 5 | 6 | const OL_BIN = require.resolve('../bin/olscheduler') 7 | 8 | const addWorkers = (configPath, urls) => { 9 | const args = urls.join(' ') 10 | return exec(`${OL_BIN} workers add -c ${configPath} ${args}`) 11 | } 12 | 13 | const removeWorkers = (configPath, urls) => { 14 | const args = urls.join(' ') 15 | return exec(`${OL_BIN} workers remove -c ${configPath} ${args}`) 16 | } 17 | 18 | module.exports = { addWorkers, removeWorkers } 19 | -------------------------------------------------------------------------------- /config/create_balancer.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import "github.com/disel-espol/olscheduler/balancer" 4 | 5 | func createBalancerFromConfig(c JSONConfig) balancer.Balancer { 6 | switch c.Balancer { 7 | case "least-loaded": 8 | return balancer.NewLeastLoadedFromJSONSlice(c.Workers) 9 | case "pkg-aware": 10 | return balancer.NewPackageAwareFromJSONSlice(c.Workers, uint(c.LoadThreshold)) 11 | case "random": 12 | return balancer.NewRandomFromJSONSlice(c.Workers) 13 | case "round-robin": 14 | return balancer.NewRoundRobinFromJSONSlice(c.Workers) 15 | case "hashing-bounded": 16 | return balancer.NewConsistentHashingBoundedFromJSONSlice(c.Workers) 17 | } 18 | 19 | panic("Unknown balancer: " + c.Balancer) 20 | } 21 | -------------------------------------------------------------------------------- /config/Config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "net/url" 5 | 6 | "github.com/disel-espol/olscheduler/balancer" 7 | "github.com/disel-espol/olscheduler/proxy" 8 | ) 9 | 10 | // Config holds then configured values and objects to be used by the scheduler. 11 | type Config struct { 12 | Host string 13 | Port int 14 | Balancer balancer.Balancer 15 | Registry map[string][]string 16 | ReverseProxy proxy.ReverseProxy 17 | } 18 | 19 | func CreateDefaultConfig() Config { 20 | return Config{ 21 | Host: "localhost", 22 | Port: 9080, 23 | Balancer: balancer.NewRoundRobin(make([]url.URL, 0)), 24 | Registry: make(map[string][]string), 25 | ReverseProxy: proxy.NewHTTPReverseProxy(), 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /test/gobetween.t.sh: -------------------------------------------------------------------------------- 1 | cd ${0%/*} 2 | mkdir ../tmp 3 | 4 | CLUSTER_NAME=../tmp/g0 5 | NUM_WORKERS=5 6 | OPENLAMBDA=../../open-lambda # Looks for open-lambda at same directory level as olscheduler repo 7 | ADMIN=$OPENLAMBDA/bin/admin 8 | HANDLERS=$OPENLAMBDA/quickstart/handlers 9 | 10 | echo "---> REMOVING CLUSTER: "$CLUSTER_NAME 11 | $ADMIN kill -cluster=$CLUSTER_NAME 12 | rm -r $CLUSTER_NAME 13 | echo 14 | echo 15 | echo 16 | echo "---> NEW CLUSTER: "$CLUSTER_NAME 17 | $ADMIN new -cluster=$CLUSTER_NAME 18 | $ADMIN workers -n=$NUM_WORKERS -cluster=$CLUSTER_NAME 19 | $ADMIN status -cluster=$CLUSTER_NAME 20 | cp -r $HANDLERS/hello $CLUSTER_NAME/registry/hello 21 | echo 22 | echo 23 | echo 24 | echo "---> TEST WORKER" 25 | curl -w "/n" -X POST localhost:8080/runLambda/hello -d '{"name": "moon"}' 26 | echo 27 | echo 28 | echo 29 | echo "---> START BALANCER" 30 | $ADMIN gobetween -cluster=$CLUSTER_NAME 31 | echo 32 | echo 33 | echo 34 | sleep 1s 35 | echo "---> TEST BALANCER" 36 | curl -w "/n" -X POST localhost:9080/runLambda/hello -d '{"name": "Moon"}' 37 | echo 38 | -------------------------------------------------------------------------------- /scheduler/}: -------------------------------------------------------------------------------- 1 | package scheduler_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "../balancer" 7 | "../scheduler" 8 | "../worker" 9 | ) 10 | 11 | func newScheduler() *scheduler.Scheduler { 12 | return scheduler.NewScheduler( 13 | make(map[string][]string), 14 | new(balancer.RoundRobinBalancer), 15 | make([]*worker.Worker, 0)) 16 | } 17 | 18 | func TestAddWorkers(t *testing.T) { 19 | s := newScheduler() 20 | 21 | s.AddWorkers([]string{"localhost:9001", "localhost:9002"}) 22 | 23 | totalWorkers := s.GetTotalWorkers() 24 | if totalWorkers != 2 { 25 | t.Errorf("Expected 2 workers but got %d instead", totalWorkers) 26 | } 27 | } 28 | 29 | func TestRemoveWorkers(t *testing.T) { 30 | s := newScheduler() 31 | 32 | s.AddWorkers([]string{"localhost:9001", "localhost:9002", "localhost:9003"}) 33 | 34 | errMsg := s.RemoveWorkers([]string{"localhost:9003"}) 35 | if errMsg != "" { 36 | t.Fatal(errMsg) 37 | } 38 | 39 | totalWorkers := s.GetTotalWorkers() 40 | if totalWorkers != 2 { 41 | t.Errorf("Expected 2 workers but got %d instead", totalWorkers) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /test/worker.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/node 2 | 3 | const http = require("http"); 4 | 5 | const delay = process.argv[2]; 6 | const port = process.argv[3]; 7 | 8 | const logRequest = req => { 9 | const time = new Date().toISOString(); 10 | const requestData = `${req.method} ${req.url}`; 11 | console.log(`[${time}] recieved request ${requestData}`); 12 | }; 13 | 14 | const sendResponse = res => { 15 | res.writeHead(200, { "Content-Type": "text/plain" }); 16 | res.end("Request handled by worker at " + port); 17 | }; 18 | 19 | const requestHandler = 20 | delay === 0 21 | ? (req, res) => { 22 | logRequest(req); 23 | sendResponse(res); 24 | } 25 | : (req, res) => 26 | setTimeout(() => { 27 | logRequest(req); 28 | sendResponse(res); 29 | }, delay * 1000); 30 | 31 | const server = http.createServer(requestHandler); 32 | 33 | const shutdown = () => { 34 | server.close(); 35 | process.exit(0); 36 | } 37 | 38 | process.on('SIGTERM', shutdown) 39 | process.on('SIGINT', shutdown) 40 | 41 | server.listen(port, "localhost"); 42 | -------------------------------------------------------------------------------- /scheduler/Scheduler_test.go: -------------------------------------------------------------------------------- 1 | package scheduler_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "../balancer" 7 | "../scheduler" 8 | "../worker" 9 | ) 10 | 11 | func newScheduler() *scheduler.Scheduler { 12 | return scheduler.NewScheduler( 13 | make(map[string][]string), 14 | new(balancer.RoundRobinBalancer), 15 | make([]*worker.Worker, 0)) 16 | } 17 | 18 | func TestAddWorkers(t *testing.T) { 19 | s := newScheduler() 20 | 21 | s.AddWorkers([]string{"localhost:9001", "localhost:9002"}) 22 | 23 | totalWorkers := s.GetTotalWorkers() 24 | if totalWorkers != 2 { 25 | t.Errorf("Expected 2 workers but got %d instead", totalWorkers) 26 | } 27 | } 28 | 29 | func TestRemoveWorkers(t *testing.T) { 30 | s := newScheduler() 31 | 32 | s.AddWorkers([]string{"localhost:9001", "localhost:9002", "localhost:9003"}) 33 | 34 | errMsg := s.RemoveWorkers([]string{"localhost:9003"}) 35 | if errMsg != "" { 36 | t.Fatal(errMsg) 37 | } 38 | 39 | totalWorkers := s.GetTotalWorkers() 40 | if totalWorkers != 2 { 41 | t.Errorf("Expected 2 workers but got %d instead", totalWorkers) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /httputil/httpreq.go: -------------------------------------------------------------------------------- 1 | package httputil 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | func ReplaceBodyWithString(r *http.Request, newStrBody string) { 10 | r.Body = ioutil.NopCloser(strings.NewReader(newStrBody)) 11 | r.ContentLength = int64(len(newStrBody)) 12 | } 13 | 14 | func GetBodyAsString(r *http.Request) string { 15 | body, _ := ioutil.ReadAll(r.Body) 16 | return string(body) 17 | } 18 | 19 | // Copied from OpenLamda src 20 | // getUrlComponents parses request URL into its "/" delimated components 21 | func GetUrlComponents(r *http.Request) []string { 22 | path := r.URL.Path 23 | // trim prefix 24 | if strings.HasPrefix(path, "/") { 25 | path = path[1:] 26 | } 27 | 28 | // trim trailing "/" 29 | if strings.HasSuffix(path, "/") { 30 | path = path[:len(path)-1] 31 | } 32 | 33 | return strings.Split(path, "/") 34 | } 35 | 36 | func Get2ndPathSegment(r *http.Request, firstSegment string) string { 37 | components := GetUrlComponents(r) 38 | 39 | if len(components) != 2 { 40 | return "" 41 | } 42 | 43 | if components[0] != firstSegment { 44 | return "" 45 | } 46 | 47 | return components[1] 48 | } 49 | -------------------------------------------------------------------------------- /test/client.js: -------------------------------------------------------------------------------- 1 | const querystring = require('querystring') 2 | const request = require('superagent') 3 | 4 | const createClient = port => { 5 | const sendRequest = ({ name, ...overrideAttrs }) => 6 | request.post(`localhost:${port}/runLambda/${name}`) 7 | .ok(res => res.status) 8 | .send({ param0: 'value0', ...(overrideAttrs || []) }) 9 | 10 | const sendRequestsSequentially = async paramsArray => { 11 | const results = [] 12 | for (let i = 0; i < paramsArray.length; i++) { 13 | const res = await sendRequest(paramsArray[i]) 14 | results.push(res) 15 | } 16 | return results 17 | } 18 | 19 | const addWorkers = async workers => { 20 | const q = querystring.stringify({ workers }) 21 | return request.post(`localhost:${port}/admin/workers/add?${q}`) 22 | .ok(res => res.status) 23 | .send() 24 | } 25 | 26 | const removeWorkers = async workers => { 27 | const q = querystring.stringify({ workers }) 28 | return request.post(`localhost:${port}/admin/workers/remove?${q}`) 29 | .ok(res => res.status) 30 | .send() 31 | } 32 | return { 33 | addWorkers, 34 | removeWorkers, 35 | sendRequest, 36 | sendRequestsSequentially 37 | } 38 | } 39 | 40 | module.exports = { createClient }; 41 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | # Golang CircleCI 2.0 configuration file 2 | # 3 | # Check https://circleci.com/docs/2.0/language-go/ for more details 4 | version: 2 5 | jobs: 6 | build: 7 | docker: 8 | # specify the version 9 | - image: circleci/golang:1-stretch 10 | 11 | # Specify service dependencies here if necessary 12 | # CircleCI maintains a library of pre-built images 13 | # documented at https://circleci.com/docs/2.0/circleci-images/ 14 | # - image: circleci/postgres:9.4 15 | 16 | working_directory: /go/src/github.com/disel-espol/olscheduler 17 | steps: 18 | - checkout 19 | 20 | # specify any bash command here prefixed with `run: ` 21 | - run: 22 | name: install deps 23 | command: bash ./deps.sh 24 | - run: 25 | name: build 26 | command: bash ./build.sh 27 | - run: 28 | name: gofmt 29 | command: diff -u <(echo -n) <(gofmt -d .) 30 | - run: 31 | name: install node 32 | command: | 33 | curl -sL https://deb.nodesource.com/setup_10.x | sudo -E bash - 34 | sudo apt-get install -y nodejs 35 | - run: 36 | name: integration tests 37 | command: | 38 | cd test 39 | npm install 40 | npm test 41 | -------------------------------------------------------------------------------- /test/round-robin.test.js: -------------------------------------------------------------------------------- 1 | const { spawnCluster } = require('./cluster.js') 2 | const { createClient } = require('./client.js') 3 | 4 | let client, cluster; 5 | 6 | 7 | describe('round-robin balancer', () => { 8 | 9 | beforeAll(async () => { 10 | cluster = await spawnCluster({ 11 | balancer: 'round-robin', 12 | port: 9010, 13 | workers: [ 14 | 'http://localhost:9011', 15 | 'http://localhost:9012', 16 | 'http://localhost:9013', 17 | 'http://localhost:9014' 18 | ] 19 | }) 20 | client = createClient(9010) 21 | }) 22 | 23 | afterAll(() => { 24 | cluster.kill(); 25 | }) 26 | 27 | it('should distribute load evenly between all workers', async () => { 28 | const requests = new Array(8).fill({ name: 'foo' }); 29 | const responses = await Promise.all(requests.map(req => client.sendRequest(req))); 30 | const responseTexts = responses.map(res => res.text).sort() 31 | 32 | expect(responseTexts).toEqual([ 33 | "Request handled by worker at 9011", 34 | "Request handled by worker at 9011", 35 | "Request handled by worker at 9012", 36 | "Request handled by worker at 9012", 37 | "Request handled by worker at 9013", 38 | "Request handled by worker at 9013", 39 | "Request handled by worker at 9014", 40 | "Request handled by worker at 9014" 41 | ]) 42 | }) 43 | }) 44 | -------------------------------------------------------------------------------- /proxy/ReverseProxy.go: -------------------------------------------------------------------------------- 1 | package proxy 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httputil" 6 | "net/url" 7 | ) 8 | 9 | // ReverseProxy is an interface for an object that proxies the client HTTP 10 | // request to a worker node. The implementation is free to choose any 11 | // protocol or network stack 12 | type ReverseProxy interface { 13 | // ProxyRequest is a function that proxies the client HTTP request to a 14 | // worker node. It takes the same parameters as a HTTP server handler along 15 | // with the worker node as the first parameter. It is expected to send a 16 | // response to the client using the ResponseWriter object. 17 | ProxyRequest(workerURL url.URL, w http.ResponseWriter, r *http.Request) 18 | } 19 | 20 | type HTTPReverseProxy struct { 21 | proxyMap map[url.URL]*httputil.ReverseProxy 22 | } 23 | 24 | func (p *HTTPReverseProxy) getReverseProxyForWorker(workerURL url.URL) *httputil.ReverseProxy { 25 | proxy := p.proxyMap[workerURL] 26 | 27 | if proxy == nil { 28 | proxy = httputil.NewSingleHostReverseProxy(&workerURL) 29 | p.proxyMap[workerURL] = proxy 30 | } 31 | 32 | return proxy 33 | } 34 | 35 | func (p *HTTPReverseProxy) ProxyRequest(workerURL url.URL, w http.ResponseWriter, r *http.Request) { 36 | proxy := p.getReverseProxyForWorker(workerURL) 37 | proxy.ServeHTTP(w, r) 38 | } 39 | 40 | func NewHTTPReverseProxy() ReverseProxy { 41 | return &HTTPReverseProxy{make(map[url.URL]*httputil.ReverseProxy)} 42 | } 43 | -------------------------------------------------------------------------------- /test/least-loaded.test.js: -------------------------------------------------------------------------------- 1 | const { spawnCluster } = require('./cluster.js') 2 | const { createClient } = require('./client.js') 3 | 4 | let client, cluster; 5 | 6 | const waitMilis = milis => new Promise((resolve, reject) => { 7 | setTimeout(() => resolve(), milis) 8 | }) 9 | 10 | describe('least-loaded balancer', () => { 11 | 12 | beforeAll(async () => { 13 | cluster = await spawnCluster({ 14 | balancer: 'least-loaded', 15 | port: 9040, 16 | workers: [ 17 | 'http://localhost:9041', 18 | 'http://localhost:9042', 19 | 'http://localhost:9043', 20 | 'http://localhost:9044' 21 | ], 22 | workerDelay: 1 23 | }) 24 | client = createClient(9040) 25 | }) 26 | 27 | afterAll(() => { 28 | cluster.kill(); 29 | }) 30 | 31 | it('4 simultaneous requests should use 4 different workers', async () => { 32 | const req = { name: 'bar' }; 33 | const responses = await Promise.all([ 34 | client.sendRequest(req), 35 | client.sendRequest(req), 36 | client.sendRequest(req), 37 | client.sendRequest(req), 38 | ]); 39 | const responseTexts = responses.map(res => res.text).sort() 40 | 41 | expect(responseTexts).toEqual([ 42 | "Request handled by worker at 9041", 43 | "Request handled by worker at 9042", 44 | "Request handled by worker at 9043", 45 | "Request handled by worker at 9044" 46 | ]); 47 | }); 48 | }); 49 | 50 | 51 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | ) 9 | 10 | type httpReq struct { 11 | path string 12 | query string 13 | port int 14 | } 15 | 16 | func sendRequest(httpReq httpReq) (int, error) { 17 | httpUrl := fmt.Sprintf("http://localhost:%d%s?%s", httpReq.port, 18 | httpReq.path, httpReq.query) 19 | resp, err := http.Post(httpUrl, "", nil) 20 | 21 | if err != nil { 22 | return 0, nil 23 | } 24 | 25 | return resp.StatusCode, nil 26 | } 27 | 28 | func encodeWorkerUrls(urls []string) string { 29 | v := url.Values{} 30 | for _, workerUrl := range urls { 31 | v.Add("workers", workerUrl) 32 | } 33 | return v.Encode() 34 | } 35 | 36 | func AddWorkers(port int, workerUrls []string) error { 37 | req := httpReq{ 38 | query: encodeWorkerUrls(workerUrls), 39 | port: port, 40 | path: "/admin/workers/add", 41 | } 42 | 43 | status, err := sendRequest(req) 44 | if err != nil { 45 | return err 46 | } 47 | 48 | if status != 200 { 49 | msg := fmt.Sprintf("Unexpected error code: %d", status) 50 | return errors.New(msg) 51 | } 52 | 53 | return nil 54 | } 55 | 56 | func RemoveWorkers(port int, workerUrls []string) error { 57 | req := httpReq{ 58 | query: encodeWorkerUrls(workerUrls), 59 | port: port, 60 | path: "/admin/workers/remove", 61 | } 62 | 63 | status, err := sendRequest(req) 64 | if err != nil { 65 | return err 66 | } 67 | 68 | if status != 200 { 69 | msg := fmt.Sprintf("Unexpected error code: %d", status) 70 | return errors.New(msg) 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /test/async requests/asyncrequests.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io/ioutil" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | func MakeRequest(url string, body []byte, ch chan<- string) { 12 | start := time.Now() 13 | resp, _ := http.Post(url, "application/json", bytes.NewBuffer(body)) 14 | 15 | secs := time.Since(start).Seconds() 16 | b, _ := ioutil.ReadAll(resp.Body) 17 | ch <- fmt.Sprintf("%.2f elapsed with response length: %d %s", secs, len(b), url) 18 | } 19 | 20 | func main() { 21 | start := time.Now() 22 | ch := make(chan string) 23 | port := 9090 24 | lambda := "hello" 25 | body := []byte(`{"pkgs": ["fmt", "rand"], "name": "Moon"}`) 26 | requests := []string{ 27 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 28 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 29 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 30 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 31 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 32 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 33 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 34 | fmt.Sprintf("http://localhost:%d/runLambda/%s", port, lambda), 35 | } 36 | for range [2]struct{}{} { 37 | requests = append(requests, requests...) 38 | } 39 | 40 | for _, url := range requests { 41 | go MakeRequest(url, body, ch) 42 | time.Sleep(500 * time.Microsecond) 43 | } 44 | 45 | for range requests { // Awaiting all responses 46 | fmt.Println(<-ch) 47 | } 48 | fmt.Printf("%.2fs elapsed\n", time.Since(start).Seconds()) 49 | } 50 | -------------------------------------------------------------------------------- /test/lambda.t.sh: -------------------------------------------------------------------------------- 1 | cd ${0%/*} 2 | mkdir ../tmp 3 | 4 | CLUSTER_NAME=../tmp/c0 5 | NUM_WORKERS=5 6 | OPENLAMBDA=../../open-lambda # Looks for open-lambda at same directory level as olscheduler repo 7 | ADMIN=$OPENLAMBDA/bin/admin 8 | HANDLERS=$OPENLAMBDA/quickstart/handlers 9 | 10 | echo "---> REMOVING CLUSTER: "$CLUSTER_NAME 11 | $ADMIN kill -cluster=$CLUSTER_NAME 12 | rm -r $CLUSTER_NAME 13 | echo 14 | echo 15 | echo 16 | echo "---> NEW CLUSTER: "$CLUSTER_NAME 17 | $ADMIN new -cluster=$CLUSTER_NAME 18 | $ADMIN workers -n=$NUM_WORKERS -cluster=$CLUSTER_NAME 19 | $ADMIN status -cluster=$CLUSTER_NAME 20 | cp -r $HANDLERS/hello $CLUSTER_NAME/registry/hello 21 | echo 22 | echo 23 | echo 24 | echo "---> TEST WORKER" 25 | curl -w "\n" -X GET localhost:8080/lambda/hello?cmd=load 26 | curl -w "\n" -X GET localhost:8080/lambda/hello?cmd=scheme 27 | curl -w "\n" -X POST localhost:8080/runLambda/hello -d '{"name": "moon"}' 28 | 29 | echo 30 | echo 31 | echo 32 | echo "---> START OLSCHEDULER" 33 | $ADMIN olscheduler -cluster=$CLUSTER_NAME -b=pkg-aware -lt=3 34 | echo 35 | echo 36 | echo 37 | sleep 1s 38 | echo "---> TEST OLSCHEDULER" 39 | curl -w "\n" -X GET localhost:9080/status 40 | curl -w "\n" -X POST localhost:9080/runLambda/hello -d '{"pkgs": ["fmt", "rand"], "name": "Moon"}' 41 | # curl -w "\n" -X POST localhost:9080/runLambda/hello -d '{"pkgs": ["fmt", "rand"]}' 42 | # curl -w "\n" -X POST localhost:9080/runLambda/hello1 -d '{"pkgs": ["strings","errors", "fmt"]}' 43 | # curl -w "\n" -X POST localhost:9080/runLambda/hello2 -d '{"pkgs": ["math", "fmt", "lol"]}' 44 | # curl -w "\n" -X POST localhost:9080/runLambda/hello3 -d '{"pkgs": ["net", "fmt", "lol"]}' 45 | 46 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d h1:U+s90UTSYgptZMwQh2aRr3LuazLJIa+Pg3Kc1ylSYVY= 3 | github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 4 | github.com/lafikl/consistent v0.0.0-20220512074542-bdd3606bfc3e h1:DuhzIzxOx3aJ0j4enY7SQ9bvulrT/XjkGAqiychfavc= 5 | github.com/lafikl/consistent v0.0.0-20220512074542-bdd3606bfc3e/go.mod h1:JmowInJuqa6EpSut8NSMAZtlvK9uL+8Q1P7tyew5rQY= 6 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1 h1:lYpkrQH5ajf0OXOcUbGjvZxxijuBwbbmlSxLiuofa+g= 7 | github.com/minio/blake2b-simd v0.0.0-20160723061019-3f5f724cb5b1/go.mod h1:pD8RvIylQ358TN4wwqatJ8rNavkEINozVn9DtGI3dfQ= 8 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 9 | github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= 10 | github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 11 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 12 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 13 | github.com/urfave/cli v1.22.9 h1:cv3/KhXGBGjEXLC4bH0sLuJ9BewaAbpk5oyMOveu4pw= 14 | github.com/urfave/cli v1.22.9/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0= 15 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 16 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 17 | -------------------------------------------------------------------------------- /balancer/roundRobin.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | 7 | "github.com/disel-espol/olscheduler/httputil" 8 | "github.com/disel-espol/olscheduler/lambda" 9 | "github.com/disel-espol/olscheduler/thread" 10 | ) 11 | 12 | type RoundRobin struct { 13 | counter *thread.Counter 14 | workerUrls []url.URL 15 | } 16 | 17 | func (b *RoundRobin) SelectWorker(r *http.Request, l *lambda.Lambda) (url.URL, *httputil.HttpError) { 18 | workerUrls := b.workerUrls 19 | var totalWorkers uint = uint(len(workerUrls)) 20 | if totalWorkers == 0 { 21 | return url.URL{}, httputil.New500Error("Can't select worker, Workers empty") 22 | } 23 | 24 | currentIndex := b.counter.Inc() % totalWorkers 25 | 26 | return workerUrls[currentIndex], nil 27 | } 28 | 29 | func (b *RoundRobin) ReleaseWorker(workerURL url.URL) { 30 | } 31 | 32 | func (b *RoundRobin) AddWorker(workerURL url.URL) { 33 | b.workerUrls = append(b.workerUrls, workerURL) 34 | } 35 | 36 | func (b *RoundRobin) RemoveWorker(targetURL url.URL) { 37 | source := b.workerUrls 38 | targetIndex := findUrlInSlice(source, targetURL) 39 | if targetIndex > -1 { 40 | b.workerUrls = append(source[:targetIndex], source[targetIndex+1:]...) 41 | } 42 | } 43 | 44 | func (b *RoundRobin) GetAllWorkers() []url.URL { 45 | workerUrls := b.workerUrls 46 | 47 | dest := make([]url.URL, len(workerUrls)) 48 | copy(dest, workerUrls) 49 | return dest 50 | } 51 | 52 | func NewRoundRobin(workerUrls []url.URL) Balancer { 53 | return &RoundRobin{thread.NewCounter(), workerUrls} 54 | } 55 | 56 | func NewRoundRobinFromJSONSlice(jsonSlice []string) Balancer { 57 | return NewRoundRobin(createWorkerURLSlice(jsonSlice)) 58 | } 59 | -------------------------------------------------------------------------------- /balancer/consistentHashingBounded.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/disel-espol/olscheduler/httputil" 9 | "github.com/disel-espol/olscheduler/lambda" 10 | 11 | "github.com/lafikl/consistent" 12 | ) 13 | 14 | type ConsistentHashingBounded struct { 15 | hashRing *consistent.Consistent 16 | workerNodeMap map[string]url.URL 17 | } 18 | 19 | func (b *ConsistentHashingBounded) SelectWorker(r *http.Request, l *lambda.Lambda) (url.URL, *httputil.HttpError) { 20 | if len(b.workerNodeMap) == 0 { 21 | return url.URL{}, httputil.New500Error("Can't select worker, Workers empty") 22 | } 23 | 24 | host, err := b.hashRing.GetLeast(r.URL.String()) 25 | if err != nil { 26 | log.Fatal(err) 27 | } 28 | 29 | b.hashRing.Inc(host) 30 | return b.workerNodeMap[host], nil 31 | } 32 | 33 | func (b *ConsistentHashingBounded) ReleaseWorker(workerUrl url.URL) { 34 | b.hashRing.Done(workerUrl.String()) 35 | } 36 | 37 | func (b ConsistentHashingBounded) AddWorker(workerUrl url.URL) { 38 | host := workerUrl.String() 39 | b.hashRing.Add(host) 40 | b.workerNodeMap[host] = workerUrl 41 | } 42 | 43 | func (b *ConsistentHashingBounded) RemoveWorker(workerUrl url.URL) { 44 | host := workerUrl.String() 45 | b.hashRing.Remove(host) 46 | delete(b.workerNodeMap, host) 47 | } 48 | 49 | func (b *ConsistentHashingBounded) GetAllWorkers() []url.URL { 50 | return getUrlsFromMap(b.workerNodeMap) 51 | } 52 | 53 | func NewConsistentHashingBounded(workerUrls []url.URL) Balancer { 54 | workerNodeMap := make(map[string]url.URL) 55 | for _, workerUrl := range workerUrls { 56 | workerNodeMap[workerUrl.String()] = workerUrl 57 | } 58 | 59 | return &ConsistentHashingBounded{consistent.New(), workerNodeMap} 60 | } 61 | 62 | func NewConsistentHashingBoundedFromJSONSlice(jsonSlice []string) Balancer { 63 | return NewConsistentHashingBounded(createWorkerURLSlice(jsonSlice)) 64 | } 65 | -------------------------------------------------------------------------------- /httputil/http.go: -------------------------------------------------------------------------------- 1 | package httputil 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | ) 7 | 8 | type HttpError struct { 9 | Msg string 10 | Code int 11 | } 12 | 13 | type AppendResponseWriter struct { 14 | headers http.Header 15 | Body []byte 16 | Status int 17 | separator []byte 18 | } 19 | 20 | func NewAppendResponseWriter() *AppendResponseWriter { 21 | return &AppendResponseWriter{headers: make(http.Header), separator: []byte("\n")} 22 | } 23 | 24 | func (this *AppendResponseWriter) Header() http.Header { 25 | return this.headers 26 | } 27 | 28 | func (this *AppendResponseWriter) Write(body []byte) (int, error) { 29 | if len(this.Body) > 0 { 30 | this.Body = append(this.Body, this.separator...) 31 | } 32 | this.Body = append(this.Body, body...) 33 | return len(this.Body), nil 34 | } 35 | 36 | func (this *AppendResponseWriter) WriteHeader(status int) { 37 | this.Status = status 38 | } 39 | 40 | type ObserverResponseWriter struct { 41 | Body []byte 42 | Status int 43 | 44 | rw http.ResponseWriter 45 | } 46 | 47 | func NewObserverResponseWriter(rw http.ResponseWriter) *ObserverResponseWriter { 48 | return &ObserverResponseWriter{rw: rw} 49 | } 50 | 51 | func (this *ObserverResponseWriter) Header() http.Header { 52 | return this.rw.Header() 53 | } 54 | 55 | func (this *ObserverResponseWriter) Write(body []byte) (int, error) { 56 | this.Body = append(this.Body, body...) 57 | return this.rw.Write(body) 58 | } 59 | 60 | func (this *ObserverResponseWriter) WriteHeader(status int) { 61 | this.Status = status 62 | this.rw.WriteHeader(status) 63 | } 64 | 65 | func New500Error(msg string) *HttpError { 66 | return &HttpError{Code: http.StatusInternalServerError, Msg: msg} 67 | } 68 | 69 | func New400Error(msg string) *HttpError { 70 | return &HttpError{Code: http.StatusBadRequest, Msg: msg} 71 | } 72 | 73 | func RespondWithError(w http.ResponseWriter, err *HttpError) { 74 | log.Printf("Could not handle request: %s\n", err.Msg) 75 | http.Error(w, err.Msg, err.Code) 76 | } 77 | -------------------------------------------------------------------------------- /balancer/util.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "log" 5 | "net/url" 6 | "strconv" 7 | 8 | "github.com/disel-espol/olscheduler/balancer/worker" 9 | ) 10 | 11 | func createWorkerURLSlice(jsonSlice []string) []url.URL { 12 | workersLength := len(jsonSlice) 13 | workerUrls := make([]url.URL, workersLength) 14 | 15 | for i, urlString := range jsonSlice { 16 | workerUrl, err := url.Parse(urlString) 17 | if err != nil { 18 | log.Fatalf("Config file Ill-formed, unable to parse URL " + urlString) 19 | } 20 | workerUrls[i] = *workerUrl 21 | } 22 | 23 | return workerUrls 24 | } 25 | 26 | func createWeightedNodeSlice(jsonSlice []string) []worker.WeightedNode { 27 | workersLength := len(jsonSlice) 28 | if workersLength%2 == 1 { 29 | log.Fatalf("Config file Ill-formed, every worker url must be followed by its weight") 30 | } 31 | 32 | workerSlice := make([]worker.WeightedNode, workersLength/2) 33 | for i := 0; i < workersLength; i = i + 2 { 34 | weight, err := strconv.Atoi(jsonSlice[i+1]) 35 | if err != nil || weight < 0 { 36 | log.Fatalf("Config file Ill-formed, every worker weight must be a positive number") 37 | } 38 | urlString := "http://" + jsonSlice[i] 39 | workerUrl, err := url.Parse(urlString) 40 | if err != nil { 41 | log.Fatalf("Config file Ill-formed, unable to parse URL " + urlString) 42 | } 43 | workerSlice[i/2] = worker.NewWeightedNode(*workerUrl, weight) 44 | } 45 | 46 | return workerSlice 47 | } 48 | 49 | func findWeightedNodeInSlice(nodeSlice []worker.WeightedNode, targetUrl url.URL) int { 50 | totalItems := len(nodeSlice) 51 | for i := 0; i < totalItems; i++ { 52 | if nodeSlice[i].GetURL() == targetUrl { 53 | return i 54 | } 55 | } 56 | return -1 57 | } 58 | 59 | func findUrlInSlice(urlSlice []url.URL, target url.URL) int { 60 | totalItems := len(urlSlice) 61 | for i := 0; i < totalItems; i++ { 62 | if urlSlice[i] == target { 63 | return i 64 | } 65 | } 66 | return -1 67 | } 68 | func getUrlsFromMap(urlMap map[string]url.URL) []url.URL { 69 | totalUrls := len(urlMap) 70 | urlSlice := make([]url.URL, totalUrls) 71 | i := 0 72 | for _, indexedUrl := range urlMap { 73 | urlSlice[i] = indexedUrl 74 | i++ 75 | } 76 | return urlSlice 77 | } 78 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/disel-espol/olscheduler/client" 7 | "github.com/disel-espol/olscheduler/config" 8 | "github.com/disel-espol/olscheduler/server" 9 | 10 | "github.com/urfave/cli" 11 | ) 12 | 13 | func createCliApp() *cli.App { 14 | app := cli.NewApp() 15 | app.Usage = "Scheduler for Open-Lambda" 16 | app.UsageText = "olscheduler COMMAND [ARG...]" 17 | app.ArgsUsage = "ArgsUsage" 18 | app.EnableBashCompletion = true 19 | app.HideVersion = true 20 | 21 | configFlag := cli.StringFlag{ 22 | Name: "config, c", 23 | Usage: "Config json file", 24 | Value: "olscheduler.json", 25 | } 26 | app.Commands = []cli.Command{ 27 | cli.Command{Name: "start", Usage: "Start Open-Lambda Scheduler", 28 | UsageText: "olscheduler start [-c|--config=FILEPATH]", 29 | Description: "The scheduler starts with settings from config json file.", 30 | Flags: []cli.Flag{configFlag}, 31 | Action: func(c *cli.Context) error { 32 | configFilepath := c.String("config") 33 | config := config.LoadConfigFromFile(configFilepath) 34 | return server.Start(config.ToConfig()) 35 | }, 36 | }, 37 | cli.Command{ 38 | Name: "workers", 39 | Usage: "Worker nodes management", 40 | Subcommands: []cli.Command{ 41 | { 42 | Name: "add", 43 | Usage: "add a new worker node to an already running scheduler", 44 | UsageText: "olscheduler worker add URL", 45 | Flags: []cli.Flag{configFlag}, 46 | Action: func(c *cli.Context) error { 47 | configFilepath := c.String("config") 48 | config := config.LoadConfigFromFile(configFilepath) 49 | return client.AddWorkers(config.Port, c.Args()) 50 | }, 51 | }, 52 | { 53 | Name: "remove", 54 | Usage: "remove an existing worker node from an already running scheduler", 55 | UsageText: "olscheduler worker remove URL", 56 | Flags: []cli.Flag{configFlag}, 57 | Action: func(c *cli.Context) error { 58 | configFilepath := c.String("config") 59 | config := config.LoadConfigFromFile(configFilepath) 60 | return client.RemoveWorkers(config.Port, c.Args()) 61 | }, 62 | }, 63 | }, 64 | }, 65 | } 66 | return app 67 | } 68 | 69 | func main() { 70 | app := createCliApp() 71 | app.Run(os.Args) 72 | } 73 | -------------------------------------------------------------------------------- /config/JSONConfig.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io/ioutil" 7 | "log" 8 | 9 | "github.com/disel-espol/olscheduler/proxy" 10 | ) 11 | 12 | // JSONConfig holds the data configured via a JSON file. This shall be used 13 | // to parse the JSON file and create a proper Config struct that dictates the 14 | // scheduler's behavior. 15 | type JSONConfig struct { 16 | Host string `json:"host"` 17 | Port int `json:"port"` 18 | Balancer string `json:"balancer"` 19 | LoadThreshold int `json:"load-threshold"` 20 | Registry string `json:"registry"` 21 | Workers []string `json:"workers"` 22 | } 23 | 24 | type Handle struct { 25 | Handle string `json:handle` 26 | Pkgs []string `json:pkgs` 27 | } 28 | 29 | func (c JSONConfig) ToConfig() Config { 30 | return Config{ 31 | Host: c.Host, 32 | Port: c.Port, 33 | Balancer: createBalancerFromConfig(c), 34 | Registry: createRegistryFromFile(c.Registry), 35 | ReverseProxy: proxy.NewHTTPReverseProxy(), 36 | } 37 | } 38 | 39 | func LoadConfigFromFile(configFilepath string) JSONConfig { 40 | var config JSONConfig 41 | file, rfErr := ioutil.ReadFile(configFilepath) 42 | if rfErr != nil { 43 | log.Fatalf("Cannot read config file (%s)", configFilepath) 44 | } 45 | decoder := json.NewDecoder(bytes.NewReader(file)) 46 | jsonErr := decoder.Decode(&config) // Parse json config file 47 | if jsonErr != nil { 48 | log.Fatalf("Config file Ill-formed (%s)", configFilepath) 49 | } 50 | 51 | if len(config.Workers)%2 != 0 { 52 | log.Fatalf("Config file Ill-formed (%s), every worker must have a weight", configFilepath) 53 | } 54 | 55 | return config 56 | } 57 | 58 | func createRegistryFromFile(registryFilePath string) map[string][]string { 59 | var handles []Handle 60 | file, rfErr := ioutil.ReadFile(registryFilePath) 61 | if rfErr != nil { 62 | log.Fatalf("Cannot read registry file (%s)", registryFilePath) 63 | } 64 | decoder := json.NewDecoder(bytes.NewReader(file)) 65 | jsonErr := decoder.Decode(&handles) // Parse json registry file 66 | if jsonErr != nil { 67 | log.Fatalf("Registry file Ill-formed (%s)", registryFilePath) 68 | } 69 | registry := make(map[string][]string) 70 | for _, handle := range handles { 71 | registry[handle.Handle] = handle.Pkgs 72 | } 73 | return registry 74 | } 75 | -------------------------------------------------------------------------------- /test/admin.test.js: -------------------------------------------------------------------------------- 1 | const { spawnCluster, spawnWorkerProcess, wait } = require('./cluster.js') 2 | const { createClient } = require('./client.js') 3 | const cli = require('./cli.js') 4 | 5 | let client, cluster; 6 | 7 | describe('HTTP endpoints for admin', () => { 8 | beforeAll(async () => { 9 | cluster = await spawnCluster({ 10 | balancer: 'pkg-aware', 11 | name: 'admin', 12 | port: 9080, 13 | workers: [] 14 | }) 15 | client = createClient(9080) 16 | }) 17 | 18 | afterAll(() => { 19 | cluster.kill(); 20 | }) 21 | it('can add and remove workers', async () => { 22 | const res1 = await client.addWorkers([ 23 | 'http://localhost:9081', 24 | 'http://localhost:9082', 25 | 'http://localhost:9083' 26 | ]); 27 | expect(res1.status).toBe(200); 28 | 29 | const res2 = await client.removeWorkers(['http://localhost:9083']); 30 | expect(res2.status).toBe(200); 31 | }) 32 | }) 33 | 34 | describe('Admin CLI', () => { 35 | beforeAll(async () => { 36 | cluster = await spawnCluster({ 37 | balancer: 'round-robin', 38 | name: 'admin', 39 | port: 9080, 40 | workers: [] 41 | }) 42 | client = createClient(9080) 43 | }) 44 | 45 | afterAll(() => { 46 | cluster.kill(); 47 | }) 48 | 49 | it('can add and remove workers', async () => { 50 | // exec admin operations 51 | const workers = [9083, 9084].map(port => spawnWorkerProcess('0', port)) 52 | await wait(1) 53 | cluster.addWorkers(workers); 54 | 55 | await cli.addWorkers( 56 | cluster.configPath, 57 | [ 58 | 'http://localhost:9081', 59 | 'http://localhost:9082', 60 | 'http://localhost:9083', 61 | 'http://localhost:9084' 62 | ] 63 | ) 64 | const { stdout } = await cli.removeWorkers( 65 | cluster.configPath, 66 | [ 67 | 'http://localhost:9081', 68 | 'http://localhost:9082' 69 | ] 70 | ) 71 | // now run the workers 72 | const requests = new Array(4).fill({ name: 'foo' }) 73 | const responses = await Promise.all(requests.map(req => client.sendRequest(req))); 74 | const responseTexts = responses.map(res => res.text).sort() 75 | 76 | expect(responseTexts).toEqual([ 77 | "Request handled by worker at 9083", 78 | "Request handled by worker at 9083", 79 | "Request handled by worker at 9084", 80 | "Request handled by worker at 9084", 81 | ]) 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /test/pkg-aware.test.js: -------------------------------------------------------------------------------- 1 | const { spawnCluster } = require('./cluster.js') 2 | const { createClient } = require('./client.js') 3 | 4 | let client, cluster; 5 | 6 | 7 | describe('pkg-aware balancer', () => { 8 | 9 | beforeAll(async () => { 10 | cluster = await spawnCluster({ 11 | balancer: 'pkg-aware', 12 | port: 9020, 13 | ['load-threshold']: 3, 14 | workers: ['http://localhost:9021', 'http://localhost:9022'] 15 | }) 16 | client = createClient(9020) 17 | }) 18 | 19 | afterAll(() => { 20 | cluster.kill(); 21 | }) 22 | 23 | it('should reuse the same worker node if the package lists are the same', async () => { 24 | const requests = new Array(3).fill({ name: 'bar' }) 25 | const responses = await client.sendRequestsSequentially(requests) 26 | const responseTexts = responses.map(res => res.text) 27 | 28 | expect(responseTexts).toEqual([ 29 | "Request handled by worker at 9022", 30 | "Request handled by worker at 9022", 31 | "Request handled by worker at 9022" 32 | ]); 33 | }); 34 | 35 | it('should use a different worker if the best one is under heavy load', async () => { 36 | const requests = new Array(4).fill({ name: 'foo' }); 37 | const responses = await Promise.all(requests.map(req => client.sendRequest(req))); 38 | const responseTexts = responses.map(res => res.text).sort() 39 | 40 | expect(responseTexts).toEqual([ 41 | "Request handled by worker at 9021", 42 | "Request handled by worker at 9021", 43 | "Request handled by worker at 9021", 44 | "Request handled by worker at 9022" 45 | ]); 46 | }); 47 | 48 | it('if the hashes of the packages lists are different, the requests should go to different nodes', async () => { 49 | // assume these 2 lists compute different hashes on olscheduler 50 | const requestFor1stNode = { name: 'foo' } 51 | const requestFor2stNode = { name: 'bar' } 52 | const requests = [ 53 | requestFor1stNode, 54 | requestFor1stNode, 55 | requestFor2stNode, 56 | requestFor1stNode, 57 | requestFor2stNode] 58 | 59 | const responses = await client.sendRequestsSequentially(requests) 60 | const responseTexts = responses.map(res => res.text) 61 | 62 | expect(responseTexts).toEqual([ 63 | "Request handled by worker at 9021", 64 | "Request handled by worker at 9021", 65 | "Request handled by worker at 9022", 66 | "Request handled by worker at 9021", 67 | "Request handled by worker at 9022" 68 | ]); 69 | }); 70 | }); 71 | -------------------------------------------------------------------------------- /balancer/leastLoaded.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "net/http" 5 | "net/url" 6 | "sync" 7 | 8 | "github.com/disel-espol/olscheduler/httputil" 9 | "github.com/disel-espol/olscheduler/lambda" 10 | ) 11 | 12 | type LeastLoaded struct { 13 | workerUrls []url.URL 14 | loadMap map[url.URL]uint 15 | mutex *sync.Mutex 16 | } 17 | 18 | func (b *LeastLoaded) getCurrentWorkerLoad(workerUrl url.URL) uint { 19 | workerLoad, _ := b.loadMap[workerUrl] 20 | return workerLoad 21 | } 22 | 23 | func (b *LeastLoaded) incrementWorkerLoad(workerUrl url.URL) { 24 | workerLoad, _ := b.loadMap[workerUrl] 25 | b.loadMap[workerUrl] = workerLoad + 1 26 | } 27 | 28 | func (b *LeastLoaded) decrementWorkerLoad(workerUrl url.URL) { 29 | workerLoad, _ := b.loadMap[workerUrl] 30 | b.loadMap[workerUrl] = workerLoad - 1 31 | } 32 | 33 | func (b *LeastLoaded) SelectWorker(r *http.Request, l *lambda.Lambda) (url.URL, *httputil.HttpError) { 34 | b.mutex.Lock() 35 | defer b.mutex.Unlock() 36 | 37 | workerUrls := b.workerUrls 38 | if len(workerUrls) == 0 { 39 | return url.URL{}, httputil.New500Error("Can't select worker, Workers empty") 40 | } 41 | 42 | leastLoadedUrl := workerUrls[0] 43 | lowestLoad := b.getCurrentWorkerLoad(leastLoadedUrl) 44 | for i := 1; i < len(workerUrls); i++ { 45 | tempUrl := workerUrls[i] 46 | tempLoad := b.getCurrentWorkerLoad(tempUrl) 47 | if tempLoad < lowestLoad { 48 | leastLoadedUrl = tempUrl 49 | lowestLoad = tempLoad 50 | } 51 | } 52 | 53 | b.incrementWorkerLoad(leastLoadedUrl) 54 | return leastLoadedUrl, nil 55 | } 56 | 57 | func (b *LeastLoaded) ReleaseWorker(workerURL url.URL) { 58 | b.mutex.Lock() 59 | defer b.mutex.Unlock() 60 | 61 | b.decrementWorkerLoad(workerURL) 62 | } 63 | 64 | func (b *LeastLoaded) AddWorker(workerURL url.URL) { 65 | b.workerUrls = append(b.workerUrls, workerURL) 66 | } 67 | 68 | func (b *LeastLoaded) GetAllWorkers() []url.URL { 69 | workerUrls := b.workerUrls 70 | 71 | dest := make([]url.URL, len(workerUrls)) 72 | copy(dest, workerUrls) 73 | return dest 74 | } 75 | 76 | func (b *LeastLoaded) RemoveWorker(targetURL url.URL) { 77 | source := b.workerUrls 78 | targetIndex := findUrlInSlice(source, targetURL) 79 | b.workerUrls = append(source[:targetIndex], source[targetIndex+1:]...) 80 | } 81 | 82 | func NewLeastLoaded(workerUrls []url.URL) Balancer { 83 | return &LeastLoaded{workerUrls, make(map[url.URL]uint), &sync.Mutex{}} 84 | } 85 | 86 | func NewLeastLoadedFromJSONSlice(jsonSlice []string) Balancer { 87 | return NewLeastLoaded(createWorkerURLSlice(jsonSlice)) 88 | } 89 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/disel-espol/olscheduler/config" 10 | "github.com/disel-espol/olscheduler/httputil" 11 | "github.com/disel-espol/olscheduler/scheduler" 12 | ) 13 | 14 | var myScheduler *scheduler.Scheduler 15 | var myConfig config.Config 16 | 17 | func parseWorkerURLs(querySlice []string) ([]url.URL, *httputil.HttpError) { 18 | totalWorkers := len(querySlice) 19 | if totalWorkers < 1 { 20 | return nil, httputil.New400Error("Workers array in query string cannot be empty") 21 | } 22 | 23 | workerUrls := make([]url.URL, totalWorkers) 24 | for i, urlString := range querySlice { 25 | workerUrl, parseErr := url.Parse(urlString) 26 | if parseErr != nil { 27 | return nil, httputil.New400Error("Malformed worker URL: " + urlString) 28 | } 29 | workerUrls[i] = *workerUrl 30 | } 31 | return workerUrls, nil 32 | } 33 | 34 | // RunLambda expects POST requests like this: 35 | // 36 | // curl -X POST localhost:9080/runLambda/ -d '{"param0": "value0"}' 37 | func runLambdaHandler(w http.ResponseWriter, r *http.Request) { 38 | log.Printf("Receive request to %s\n", r.URL.Path) 39 | 40 | observer := httputil.NewObserverResponseWriter(w) 41 | myScheduler.RunLambda(observer, r) 42 | 43 | log.Printf("Response Status: %d", observer.Status) 44 | } 45 | 46 | func statusHandler(w http.ResponseWriter, r *http.Request) { 47 | appendResponseWriter := httputil.NewAppendResponseWriter() 48 | myScheduler.StatusCheckAllWorkers(appendResponseWriter, r) 49 | } 50 | 51 | func addWorkerHandler(w http.ResponseWriter, r *http.Request) { 52 | workers := r.URL.Query()["workers"] 53 | 54 | workerUrls, err := parseWorkerURLs(workers) 55 | if err != nil { 56 | httputil.RespondWithError(w, err) 57 | return 58 | } 59 | myScheduler.AddWorkers(workerUrls) 60 | } 61 | 62 | func removeWorkerHandler(w http.ResponseWriter, r *http.Request) { 63 | workers := r.URL.Query()["workers"] 64 | 65 | workerUrls, err := parseWorkerURLs(workers) 66 | if err != nil { 67 | httputil.RespondWithError(w, err) 68 | return 69 | } 70 | myScheduler.RemoveWorkers(workerUrls) 71 | } 72 | 73 | func Start(c config.Config) error { 74 | myConfig = c 75 | myScheduler = scheduler.NewScheduler(c) 76 | 77 | http.HandleFunc("/runLambda/", runLambdaHandler) 78 | http.HandleFunc("/status", statusHandler) 79 | http.HandleFunc("/admin/workers/add", addWorkerHandler) 80 | http.HandleFunc("/admin/workers/remove", removeWorkerHandler) 81 | 82 | url := fmt.Sprintf("%s:%d", myConfig.Host, myConfig.Port) 83 | return http.ListenAndServe(url, nil) 84 | } 85 | -------------------------------------------------------------------------------- /balancer/random.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "math/rand" 5 | "net/http" 6 | "net/url" 7 | "time" 8 | 9 | "github.com/disel-espol/olscheduler/balancer/worker" 10 | "github.com/disel-espol/olscheduler/httputil" 11 | "github.com/disel-espol/olscheduler/lambda" 12 | ) 13 | 14 | // Select a random worker 15 | type Random struct { 16 | workerNodes []worker.WeightedNode 17 | } 18 | 19 | func calculateTotalWeight(workers []worker.WeightedNode) int { 20 | totalWeight := 0 21 | for i, _ := range workers { 22 | if workers[i].GetWeight() < 0 { 23 | } 24 | totalWeight += workers[i].GetWeight() 25 | } 26 | return totalWeight 27 | } 28 | 29 | func chooseFromWeightedRandomValue(workers []worker.WeightedNode, totalWeight int, randomValue int) (url.URL, *httputil.HttpError) { 30 | accumWeight := 0 31 | for i, _ := range workers { 32 | if workers[i].GetWeight() == 0 { 33 | continue 34 | } 35 | accumWeight += workers[i].GetWeight() 36 | if accumWeight >= randomValue { 37 | return workers[i].GetURL(), nil 38 | } 39 | } 40 | 41 | return url.URL{}, httputil.New500Error("Can't select worker, All weights are zero") 42 | } 43 | 44 | func (b *Random) SelectWorker(r *http.Request, l *lambda.Lambda) (url.URL, *httputil.HttpError) { 45 | workers := b.workerNodes 46 | totalWorkers := len(workers) 47 | if totalWorkers == 0 { 48 | return url.URL{}, httputil.New500Error("Can't select worker, Workers empty") 49 | } 50 | totalWeight := calculateTotalWeight(workers) 51 | weightedRandomValue := rand.Intn(totalWeight) + 1 52 | return chooseFromWeightedRandomValue(workers, totalWeight, weightedRandomValue) 53 | } 54 | 55 | func (b *Random) AddWorker(workerURL url.URL) { 56 | b.workerNodes = append(b.workerNodes, worker.NewWeightedNode(workerURL, 1)) 57 | } 58 | 59 | func (b *Random) ReleaseWorker(workerURL url.URL) { 60 | } 61 | 62 | func (b *Random) RemoveWorker(targetURL url.URL) { 63 | source := b.workerNodes 64 | targetIndex := findWeightedNodeInSlice(source, targetURL) 65 | if targetIndex > -1 { 66 | b.workerNodes = append(source[:targetIndex], source[targetIndex+1:]...) 67 | } 68 | } 69 | 70 | func (b *Random) GetAllWorkers() []url.URL { 71 | workerNodes := b.workerNodes 72 | totalWorkers := len(workerNodes) 73 | workerUrls := make([]url.URL, totalWorkers) 74 | 75 | for i, indexedNode := range workerNodes { 76 | workerUrls[i] = indexedNode.GetURL() 77 | } 78 | return workerUrls 79 | } 80 | 81 | func NewRandom(workerNodes []worker.WeightedNode) Balancer { 82 | rand.Seed(time.Now().Unix()) // For rand future calls 83 | return &Random{workerNodes} 84 | } 85 | 86 | func NewRandomFromJSONSlice(jsonSlice []string) Balancer { 87 | return NewRandom(createWeightedNodeSlice(jsonSlice)) 88 | } 89 | -------------------------------------------------------------------------------- /scheduler/Scheduler.go: -------------------------------------------------------------------------------- 1 | package scheduler 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | 8 | "github.com/disel-espol/olscheduler/balancer" 9 | "github.com/disel-espol/olscheduler/config" 10 | "github.com/disel-espol/olscheduler/httputil" 11 | "github.com/disel-espol/olscheduler/lambda" 12 | "github.com/disel-espol/olscheduler/proxy" 13 | ) 14 | 15 | // Scheduler is an object that can schedule lambda function workloads to a pool 16 | // of workers. 17 | type Scheduler struct { 18 | registry map[string][]string 19 | balancer balancer.Balancer 20 | proxy proxy.ReverseProxy 21 | } 22 | 23 | func (s *Scheduler) GetLambdaInfoFromRequest(r *http.Request) (*lambda.Lambda, 24 | *httputil.HttpError) { 25 | lambdaName := httputil.Get2ndPathSegment(r, "runLambda") 26 | 27 | if lambdaName == "" { 28 | return nil, &httputil.HttpError{ 29 | fmt.Sprintf("Could not find lambda name in path %s", r.URL.Path), 30 | http.StatusBadRequest} 31 | } 32 | 33 | pkgs, found := s.registry[lambdaName] 34 | if !found { 35 | return nil, &httputil.HttpError{ 36 | fmt.Sprintf("No pkgs found in registry for lambda name: %s", 37 | lambdaName), 38 | http.StatusBadRequest} 39 | } 40 | 41 | return &lambda.Lambda{lambdaName, pkgs}, nil 42 | } 43 | 44 | func (s *Scheduler) StatusCheckAllWorkers(w http.ResponseWriter, r *http.Request) { 45 | for _, workerUrl := range s.balancer.GetAllWorkers() { 46 | s.proxy.ProxyRequest(workerUrl, w, r) 47 | } 48 | } 49 | 50 | // RunLambda is an HTTP request handler that expects requests of form 51 | // /runLambda/. It extracts the lambda name from the request path 52 | // and then chooses a worker to run the lambda workload using the configured 53 | // load balancer. The lambda response is forwarded to the client "as-is" 54 | // without any modifications. 55 | func (s *Scheduler) RunLambda(w http.ResponseWriter, r *http.Request) { 56 | lambda, err := s.GetLambdaInfoFromRequest(r) 57 | 58 | if err != nil { 59 | httputil.RespondWithError(w, err) 60 | return 61 | } 62 | 63 | // Select worker and serve http 64 | selectedWorkerURL, err := s.balancer.SelectWorker(r, lambda) 65 | if err != nil { 66 | httputil.RespondWithError(w, err) 67 | return 68 | } 69 | 70 | s.proxy.ProxyRequest(selectedWorkerURL, w, r) 71 | s.balancer.ReleaseWorker(selectedWorkerURL) 72 | } 73 | 74 | func (s *Scheduler) AddWorkers(urls []url.URL) { 75 | for _, workerURL := range urls { 76 | s.balancer.AddWorker(workerURL) 77 | } 78 | } 79 | func (s *Scheduler) RemoveWorkers(urls []url.URL) { 80 | for _, workerURL := range urls { 81 | s.balancer.RemoveWorker(workerURL) 82 | } 83 | } 84 | 85 | func NewScheduler(c config.Config) *Scheduler { 86 | return &Scheduler{ 87 | c.Registry, 88 | c.Balancer, 89 | c.ReverseProxy, 90 | } 91 | 92 | } 93 | -------------------------------------------------------------------------------- /test/cluster.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { promisify } = require('util') 3 | const { spawn } = require('child_process'); 4 | 5 | const writeFile = promisify(fs.writeFile) 6 | const OL_BIN = require.resolve('../bin/olscheduler') 7 | const WORKER_JS = require.resolve('./worker.js') 8 | 9 | const abortOnErrorHandler = err => { 10 | if (err) 11 | console.error('Failed to initialize test cluster: ', err) 12 | } 13 | 14 | const writeJSONFile = (filePath, obj) => { 15 | // pretty print the JSON object 16 | const configText = JSON.stringify(obj, null, 4) 17 | 18 | return writeFile(filePath, configText) 19 | .then(() => Promise.resolve(filePath)) 20 | } 21 | 22 | const createRegistryConfig = () => { 23 | const entries = [ 24 | { 25 | handle: 'foo', 26 | pkgs: [ 27 | 'pkg0', 28 | 'pkg1' 29 | ] 30 | }, 31 | { 32 | handle: 'bar', 33 | pkgs: [ 34 | 'z17922!', 35 | 'pkg2' 36 | ] 37 | } 38 | ] 39 | const filePath = '/tmp/olscheduler-registry.json'; 40 | return writeJSONFile(filePath, entries); 41 | } 42 | 43 | 44 | const createOlschedulerConfig = async overridenOpts => { 45 | const baseConfig = { 46 | host: 'localhost', 47 | port: 9080, 48 | ['load-threshold']: 3, 49 | registry: await createRegistryConfig() 50 | } 51 | const name = overridenOpts.name || overridenOpts.balancer 52 | const filePath = `/tmp/olscheduler-${name}.json` 53 | return writeJSONFile(filePath, { ...baseConfig, ...overridenOpts }) 54 | } 55 | 56 | const spawnOlschedulerProcess = async overridenOpts => { 57 | const configPath = await createOlschedulerConfig(overridenOpts) 58 | const cp = spawn(OL_BIN, ['start', '-c', configPath]) 59 | if (process.env.DEBUG) 60 | cp.stderr.on('data', data => console.log('[OLS]: ' + data.toString())); 61 | return { cp, configPath } 62 | } 63 | 64 | const spawnWorkerProcess = (delay, port) => { 65 | const cp = spawn('node', [WORKER_JS, delay.toString(), port]) 66 | return cp 67 | } 68 | 69 | const wait = seconds => new Promise((resolve, reject) => { 70 | setTimeout(() => resolve(), seconds * 1000) 71 | }) 72 | 73 | const spawnCluster = async opts => { 74 | const { workerDelay, ...overridenOpts } = opts; 75 | const workerProcesses = opts.workers 76 | .map(workerUrlString => new URL(workerUrlString).port) 77 | .map(workerPort => spawnWorkerProcess(workerDelay || '0', workerPort)) 78 | 79 | const { 80 | cp: olProcess, 81 | configPath 82 | } = await spawnOlschedulerProcess(opts) 83 | 84 | // wait 2 seconds for the server to launch 85 | await wait(2) 86 | 87 | return { 88 | configPath, 89 | kill: () => { 90 | olProcess.kill() 91 | workerProcesses.forEach(w => w.kill()) 92 | }, 93 | addWorkers: newWorkers => { 94 | workerProcesses.push.apply(workerProcesses, newWorkers) 95 | } 96 | } 97 | } 98 | 99 | module.exports = { 100 | spawnCluster, 101 | spawnWorkerProcess, 102 | wait 103 | } 104 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # olscheduler [![CircleCI](https://circleci.com/gh/disel-espol/olscheduler.svg?style=svg)](https://circleci.com/gh/disel-espol/olscheduler) 2 | 3 | Extensible scheduler for OpenLambda written in Go. 4 | 5 | ## Installation 6 | 7 | Once you have the Go runtime installed in your system just run: 8 | 9 | ``` bash 10 | go get github.com/disel-espol/olscheduler 11 | ``` 12 | 13 | ## Usage 14 | 15 | This program launches an HTTP server that proxies incoming HTTP requests to the 16 | HTTP servers that will actually handle the requests, known as "worker nodes". 17 | Please note that it is your job to launch the worker nodes and manage them. You 18 | can configure almost everything about the scheduler via a Go API or a JSON 19 | configuration file using the CLI API. 20 | 21 | ### CLI API 22 | 23 | First you must create a configuration file. Here's an example of a configuration 24 | to run a scheduler listening on port 9020 using the "Package Aware" load 25 | balancing algorithm with two worker nodes listening on ports 9021, and 9022 26 | respectively. 27 | 28 | ``` JSON 29 | { 30 | "host":"localhost", 31 | "port":9020, 32 | "load-threshold":3, 33 | "registry":"/tmp/olscheduler-registry.json", 34 | "balancer":"pkg-aware", 35 | "workers": [ 36 | "http://localhost:9021", 37 | "http://localhost:9022" 38 | ] 39 | } 40 | ``` 41 | 42 | `/tmp/olscheduler-registry.json` is another JSON file with an array information 43 | about the handler functions. This data is currently only used by the 44 | "Package Aware" algorithm. Each item in the array has two attributes: 45 | 46 | - `handle`. The name of the handler funcion. 47 | - `pkgs`. An array of unique identifier names for the packages that the handler 48 | function requires as dependencies. 49 | 50 | Here's an example of a registry file with two handler functions: `foo` and `bar`. 51 | 52 | ``` JSON 53 | [ 54 | { 55 | "handle":"foo", 56 | "pkgs":["pkg0","pkg1"] 57 | }, 58 | { 59 | "handle":"bar", 60 | "pkgs":["pkg0","pkg2"] 61 | } 62 | ] 63 | ``` 64 | 65 | Once the files are ready you can launch the scheduler by running: 66 | 67 | ``` bash 68 | olscheduler start -c /path/to/config/file 69 | ``` 70 | 71 | ### Go API 72 | 73 | This is the simplest method, provided that you are comfortable with Go. Just 74 | create a `Config` struct with the desired configuration and then pass it to the 75 | `server.Start()` function to start the server. Here's an example that creates 76 | the same scheduler as the one in the previous section: 77 | 78 | ``` Go 79 | package main 80 | 81 | import ( 82 | "net/url" 83 | 84 | "github.com/disel-espol/olscheduler/balancer" 85 | "github.com/disel-espol/olscheduler/config" 86 | "github.com/disel-espol/olscheduler/server" 87 | ) 88 | 89 | func main() { 90 | myConfig := config.CreateDefaultConfig() 91 | 92 | // edit config as you wish 93 | myConfig.Port = 9020 94 | 95 | // add functions to registry 96 | myConfig.Registry["foo"] = []string{"pkg0", "pkg1"} 97 | myConfig.Registry["bar"] = []string{"pkg0", "pkg2"} 98 | 99 | // create workers and set load balancer algorithm 100 | var loadThreshold uint = 3 101 | workerUrls := []url.URL{ 102 | url.URL{Scheme: "http", Host: "localhost:8081"}, 103 | url.URL{Scheme: "http", Host: "localhost:8082"}, 104 | } 105 | myConfig.Balancer = balancer.NewPackageAware(workerUrls, loadThreshold) 106 | 107 | // launch the server 108 | server.Start(myConfig) 109 | } 110 | ``` 111 | 112 | 113 | -------------------------------------------------------------------------------- /balancer/pkgAware.go: -------------------------------------------------------------------------------- 1 | package balancer 2 | 3 | import ( 4 | "errors" 5 | "log" 6 | "net/http" 7 | "net/url" 8 | "sync" 9 | 10 | "github.com/disel-espol/olscheduler/balancer/worker" 11 | "github.com/disel-espol/olscheduler/httputil" 12 | "github.com/disel-espol/olscheduler/lambda" 13 | 14 | "github.com/lafikl/consistent" 15 | ) 16 | 17 | func findWorkerNodeInSlice(workerNodeSlice []*worker.Node, target url.URL) int { 18 | totalItems := len(workerNodeSlice) 19 | for i := 0; i < totalItems; i++ { 20 | if workerNodeSlice[i].GetURL() == target { 21 | return i 22 | } 23 | } 24 | return -1 25 | } 26 | 27 | type PackageAware struct { 28 | hashRing *consistent.Consistent 29 | loadThreshold uint 30 | workerNodes []*worker.Node 31 | workerNodeMap map[string]*worker.Node 32 | mutex *sync.Mutex 33 | } 34 | 35 | func getPotentialNode(largestPkg string, b *PackageAware) (*worker.Node, error) { 36 | 37 | salt := "salty" 38 | candidate1, err1 := b.hashRing.Get(largestPkg) 39 | candidate2, err2 := b.hashRing.Get(largestPkg + salt) 40 | if err1 != nil && err2 != nil { 41 | log.Fatal(err1, err2) 42 | return nil, errors.New("error in both candidates") 43 | } else if err1 != nil { 44 | return b.workerNodeMap[candidate2], nil 45 | } else if err2 != nil { 46 | return b.workerNodeMap[candidate1], nil 47 | } 48 | 49 | host1 := b.workerNodeMap[candidate1] 50 | host2 := b.workerNodeMap[candidate2] 51 | if host1.Load >= host2.Load { 52 | return host2, nil 53 | } 54 | 55 | return host1, nil 56 | 57 | } 58 | 59 | func (b *PackageAware) SelectWorker(r *http.Request, l *lambda.Lambda) (url.URL, *httputil.HttpError) { 60 | workerNodes := b.workerNodes 61 | if len(workerNodes) == 0 { 62 | return url.URL{}, httputil.New500Error("Can't select worker, Workers empty") 63 | } 64 | 65 | pkgs := l.Pkgs 66 | if len(pkgs) == 0 { 67 | return url.URL{}, httputil.New500Error("Can't select worker, No largest package, pkgs empty") 68 | } 69 | 70 | largestPkg := pkgs[0] 71 | 72 | selectedNode, err := getPotentialNode(largestPkg, b) 73 | if err != nil { 74 | log.Fatal(err) 75 | return url.URL{}, httputil.New500Error("Failed to select worker. URL not found") 76 | } 77 | 78 | b.mutex.Lock() 79 | defer b.mutex.Unlock() 80 | 81 | if selectedNode.Load >= b.loadThreshold { // Find least loaded 82 | selectedNode = b.selectLeastLoadedWorker() 83 | } 84 | 85 | selectedNode.Load++ 86 | 87 | return selectedNode.GetURL(), nil 88 | 89 | } 90 | 91 | func (b *PackageAware) ReleaseWorker(workerUrl url.URL) { 92 | selectedNode := b.workerNodeMap[workerUrl.String()] 93 | if selectedNode != nil { 94 | b.mutex.Lock() 95 | defer b.mutex.Unlock() 96 | 97 | selectedNode.Load-- 98 | } 99 | } 100 | 101 | func (b *PackageAware) AddWorker(workerUrl url.URL) { 102 | host := workerUrl.String() 103 | node := worker.NewNode(workerUrl) 104 | b.workerNodes = append(b.workerNodes, node) 105 | b.hashRing.Add(host) 106 | b.workerNodeMap[host] = node 107 | } 108 | 109 | func (b *PackageAware) RemoveWorker(workerUrl url.URL) { 110 | host := workerUrl.String() 111 | source := b.workerNodes 112 | targetIndex := findWorkerNodeInSlice(source, workerUrl) 113 | if targetIndex > -1 { 114 | b.workerNodes = append(source[:targetIndex], source[targetIndex+1:]...) 115 | b.hashRing.Remove(host) 116 | b.workerNodeMap[host] = nil 117 | } 118 | } 119 | 120 | func (b *PackageAware) selectLeastLoadedWorker() *worker.Node { 121 | targetIndex := 0 122 | workers := b.workerNodes 123 | for i := 1; i < len(workers); i++ { 124 | if workers[i].Load < workers[targetIndex].Load { 125 | targetIndex = i 126 | } 127 | } 128 | return workers[targetIndex] 129 | } 130 | 131 | func (b *PackageAware) GetAllWorkers() []url.URL { 132 | workerNodes := b.workerNodes 133 | totalWorkers := len(workerNodes) 134 | workerUrls := make([]url.URL, totalWorkers) 135 | 136 | for i, indexedNode := range workerNodes { 137 | workerUrls[i] = indexedNode.GetURL() 138 | } 139 | return workerUrls 140 | } 141 | 142 | func NewPackageAware(workerUrls []url.URL, loadThreshold uint) Balancer { 143 | totalUrls := len(workerUrls) 144 | 145 | workerNodes := make([]*worker.Node, totalUrls) 146 | workerNodeMap := make(map[string]*worker.Node) 147 | hashRing := consistent.New() 148 | 149 | for i, workerUrl := range workerUrls { 150 | urlString := workerUrl.String() 151 | workerNodes[i] = worker.NewNode(workerUrl) 152 | hashRing.Add(urlString) 153 | workerNodeMap[urlString] = workerNodes[i] 154 | } 155 | 156 | return &PackageAware{ 157 | hashRing, 158 | loadThreshold, 159 | workerNodes, 160 | workerNodeMap, 161 | &sync.Mutex{}} 162 | } 163 | 164 | func NewPackageAwareFromJSONSlice(jsonSlice []string, loadThreshold uint) Balancer { 165 | return NewPackageAware(createWorkerURLSlice(jsonSlice), loadThreshold) 166 | } 167 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | --------------------------------------------------------------------------------