Skip to content

Commit 0c1e898

Browse files
committed
feat: simple leader election using zookeeper
1 parent d416864 commit 0c1e898

File tree

6 files changed

+267
-0
lines changed

6 files changed

+267
-0
lines changed

.editorconfig

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
root = true
2+
3+
[*]
4+
indent_style = space
5+
indent_size = 2
6+
end_of_line = lf
7+
charset = utf-8
8+
trim_trailing_whitespace = true
9+
insert_final_newline = true
10+
11+
[*.md]
12+
trim_trailing_whitespace = false

.travis.yml

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
language: node_js
2+
cache:
3+
directories:
4+
- node_modules
5+
notifications:
6+
email: false
7+
node_js:
8+
- '8'
9+
- '7'
10+
- '6'
11+
sudo: required
12+
services:
13+
- docker
14+
before_install:
15+
- docker --version
16+
- sudo apt-get update
17+
- sudo apt-get -y -o Dpkg::Options::="--force-confnew" install docker-engine
18+
before_script:
19+
- npm prune
20+
after_success:
21+
- npm run semantic-release
22+
branches:
23+
except:
24+
- /^v\d+\.\d+\.\d+$/

docker-compose.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
version: '2'
2+
services:
3+
zookeeper:
4+
image: jplock/zookeeper
5+
ports:
6+
- "2181:2181"

index.js

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
'use strict'
2+
3+
const debug = require('debug')('elector')
4+
const zookeeper = require('node-zookeeper-client')
5+
const async = require('async')
6+
const _ = require('lodash')
7+
const EventEmitter = require('events')
8+
9+
class Elector extends EventEmitter {
10+
constructor (zkOptions, electionPath = '/election') {
11+
super()
12+
this.id = null
13+
this.electionPath = electionPath
14+
this.client = zookeeper.createClient(zkOptions.host)
15+
this.client.once('connected', () => this._onConnect())
16+
}
17+
18+
connect () {
19+
this.client.connect()
20+
}
21+
22+
_onConnect () {
23+
debug('connected!')
24+
async.waterfall(
25+
[
26+
callback => {
27+
debug('creating election path "%s"', this.electionPath)
28+
this.client.mkdirp(this.electionPath, callback)
29+
},
30+
31+
(path, callback) => {
32+
debug('created path: "%s"', path)
33+
debug('creating EPHEMERAL_SEQUENTIAL node')
34+
this.client.create(
35+
`${this.electionPath}/p_`,
36+
zookeeper.CreateMode.EPHEMERAL_SEQUENTIAL,
37+
callback
38+
)
39+
},
40+
41+
(path, callback) => {
42+
debug('newly created znode is "%s"', path)
43+
this.id = _.last(path.split('/'))
44+
debug('my candidateId is', this.id)
45+
this.emit('candidateId', this.id)
46+
this._listCandidates(callback)
47+
}
48+
],
49+
(error, candidates) => {
50+
if (error) {
51+
return console.error(error)
52+
}
53+
debug('received candidates', candidates)
54+
this._pickLeader(candidates)
55+
}
56+
)
57+
}
58+
59+
_pickLeader (candidates) {
60+
const previousLeadershipState = this.isLeader
61+
this.isLeader = _.first(candidates.sort()) === this.id
62+
63+
if (previousLeadershipState !== this.isLeader) {
64+
if (this.isLeader) {
65+
debug('%s I am the leader!', this.id)
66+
this.emit('leader')
67+
} else {
68+
this.emit('follower')
69+
debug('%s I am a follower', this.id)
70+
}
71+
} else {
72+
debug('%s state is the same', this.id)
73+
}
74+
}
75+
76+
_onCandidateChange (event) {
77+
if (this.disconnecting) {
78+
return
79+
}
80+
81+
this._listCandidates((error, candidates) => {
82+
if (error) {
83+
debug(error)
84+
this.emit('error', error)
85+
return
86+
}
87+
debug('new candidates', candidates)
88+
this._pickLeader(candidates)
89+
})
90+
}
91+
92+
_listCandidates (callback) {
93+
this.client.getChildren(
94+
this.electionPath,
95+
event => this._onCandidateChange(event),
96+
(error, candidates, stats) => {
97+
if (error) {
98+
return callback(error)
99+
}
100+
callback(null, candidates)
101+
}
102+
)
103+
}
104+
105+
disconnect () {
106+
debug('disconnecting')
107+
this.disconnecting = true
108+
this.client.remove(`${this.electionPath}/${this.id}`, error => {
109+
this.client.close()
110+
if (error) {
111+
return debug(error)
112+
}
113+
})
114+
}
115+
}
116+
117+
module.exports = Elector

package.json

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"name": "elector",
3+
"version": "0.0.0-development",
4+
"description": "simple zookeeper based leader election",
5+
"main": "index.js",
6+
"scripts": {
7+
"test": "standard && docker-compose up -d && mocha",
8+
"format": "prettier-standard '*.js'",
9+
"semantic-release": "semantic-release pre && npm publish && semantic-release post"
10+
},
11+
"repository": {
12+
"type": "git",
13+
"url": "https://github.com/hyperlink/elector.git"
14+
},
15+
"keywords": [
16+
"zookeeper",
17+
"leadership",
18+
"election"
19+
],
20+
"engines": {
21+
"node": ">6.0.0"
22+
},
23+
"author": "Xiaoxin Lu <[email protected]>",
24+
"license": "MIT",
25+
"bugs": {
26+
"url": "https://github.com/hyperlink/elector/issues"
27+
},
28+
"homepage": "https://github.com/hyperlink/elector#readme",
29+
"dependencies": {
30+
"async": "^2.4.1",
31+
"debug": "^2.6.8",
32+
"lodash": "^4.17.4",
33+
"node-zookeeper-client": "^0.2.2"
34+
},
35+
"devDependencies": {
36+
"husky": "^0.13.4",
37+
"lint-staged": "^3.6.1",
38+
"mocha": "^3.4.2",
39+
"prettier-standard": "^5.1.0",
40+
"semantic-release": "^6.3.6",
41+
"standard": "^10.0.2"
42+
},
43+
"lint-staged": {
44+
"linters": {
45+
"src/**/*.js": [
46+
"prettier-standard",
47+
"git add"
48+
]
49+
}
50+
}
51+
}

test/index.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/* eslint-env mocha */
2+
3+
'use strict'
4+
5+
const Elector = require('../')
6+
const async = require('async')
7+
const _ = require('lodash')
8+
const assert = require('assert')
9+
10+
describe('Elector', function () {
11+
let electors = []
12+
13+
afterEach(function () {
14+
for (let elector of electors) {
15+
elector.disconnect()
16+
}
17+
})
18+
19+
it('should elect one node out of the five as leader', function (done) {
20+
electors = _.times(5, createElector)
21+
22+
assert(electors.length, 5)
23+
24+
const leaders = []
25+
const followers = []
26+
27+
async.each(
28+
electors,
29+
function (elector, callback) {
30+
elector.connect()
31+
function cb (error) {
32+
if (error) {
33+
return callback(error)
34+
}
35+
if (this.isLeader) {
36+
leaders.push(this.id)
37+
} else {
38+
followers.push(this.id)
39+
}
40+
callback(null)
41+
}
42+
elector.on('leader', cb)
43+
elector.on('follower', cb)
44+
elector.on('error', callback)
45+
},
46+
function (error) {
47+
assert.equal(leaders.length, 1)
48+
assert.equal(followers.length, 4)
49+
done(error)
50+
}
51+
)
52+
})
53+
})
54+
55+
function createElector () {
56+
return new Elector({ host: 'localhost:2181' })
57+
}

0 commit comments

Comments
 (0)