├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── example └── in-memory.js ├── package.json ├── scripts ├── example.js └── run.js ├── src ├── fuzz │ ├── Configuration.js │ ├── Network.js │ ├── QueueManager.js │ ├── Reporter.js │ ├── Runner.js │ ├── ServerState.js │ ├── Simulation.js │ ├── event-generator.js │ ├── int-in-range.js │ ├── pick-random.js │ ├── predicate-violations.js │ └── run.js └── lib │ ├── Address.js │ ├── Entry.js │ ├── InputConsumer.js │ ├── Log.js │ ├── LogEntryApplier.js │ ├── MessageBuffer.js │ ├── NonPeerReceiver.js │ ├── Peer.js │ ├── Raft.js │ ├── Scheduler.js │ ├── Server.js │ ├── State.js │ ├── Timers.js │ ├── expose-events.js │ ├── main.js │ ├── process.js │ ├── roles │ ├── Candidate.js │ ├── Follower.js │ └── Leader.js │ └── symbols.js └── test └── lib ├── Address.js ├── Candidate.js ├── Entry.js ├── Follower.js ├── InputConsumer.js ├── Leader.js ├── Log.js ├── LogEntryApplier.js ├── MessageBuffer.js ├── NonPeerReceiver.js ├── Peer.js ├── Raft.js ├── Scheduler.js ├── Server.js ├── State.js ├── Timers.js ├── expose-events.js ├── helpers ├── dist.js ├── fork-context.js ├── macro.js ├── role-tests.js └── stub-helpers.js └── main.js /.gitignore: -------------------------------------------------------------------------------- 1 | .node-version 2 | .nyc_output 3 | node_modules 4 | coverage 5 | 6 | dist 7 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | script: npm test 5 | notifications: 6 | email: false 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License (ISC) 2 | Copyright (c) 2015–2016, Mark Wubben 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any purpose 5 | with or without fee is hereby granted, provided that the above copyright notice 6 | and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 9 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 10 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 11 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 12 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 13 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 14 | THIS SOFTWARE. 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Buoyant 2 | 3 | Buoyant's goal is to make it easier to build distributed systems that fit your 4 | needs. 5 | 6 | Servers in distributed systems need to agree with each other on their state. 7 | Buoyant implements the [Raft Consensus Algorithm](https://raft.github.io), a 8 | relatively understandable algorithm for ensuring all servers agree on their 9 | state. This allows you to build a reliable, distributed system that fits your 10 | needs. You get to decide how state is stored and how servers communicate with 11 | each other. Eventually plugins will be available to make this even easier. 12 | 13 | ## Raft overview 14 | 15 | Servers form a cluster. There is a single leader that is allowed to modify its 16 | state. The other servers are followers and will replicate the leader's state. If 17 | the leader crashes the followers automatically elect a new leader without losing 18 | any data. A cluster of three servers can survive the loss of one and still 19 | function. A cluster of five servers can lose two. 20 | 21 | State is modified using deterministic commands that are applied in-order. The 22 | leader replicates these commands to (at least half of) its followers before 23 | applying them to its own state. Then the followers do the same. This ensures 24 | that each server, eventually, has the same state as the leader. 25 | 26 | [The Raft website has more details](https://raft.github.io). 27 | 28 | ## Is Buoyant production ready? 29 | 30 | No. The 1.0 release will be the first production-ready version. 31 | 32 | ## Requirements 33 | 34 | Buoyant requires at least [Node.js](https://nodejs.org) v6.2.2. 35 | 36 | ## Installation 37 | 38 | ``` 39 | npm install --save buoyant 40 | ``` 41 | 42 | ## Roadmap 43 | 44 | * Add proper error types for internal errors 45 | * Implement a TCP transport plugin 46 | * Implement a state persistence plugin 47 | * Document the public API, implementation requirements, how to build custom 48 | transports 49 | * 1.0 release 50 | * Support client interactions 51 | * Support modifying the cluster 52 | * Support snapshotting & log compaction 53 | * Implement the leadership transfer extension 54 | -------------------------------------------------------------------------------- /example/in-memory.js: -------------------------------------------------------------------------------- 1 | import { Duplex, Readable } from 'stream' 2 | import chalk from 'chalk' 3 | 4 | import { createServer } from '../' 5 | 6 | const inputWritters = new Map() 7 | 8 | class Transport { 9 | constructor (fromAddress) { 10 | this.fromAddress = fromAddress 11 | this.nonPeerStream = new Readable({ objectMode: true, read () {} }) 12 | } 13 | 14 | listen () { 15 | return this.nonPeerStream 16 | } 17 | 18 | connect ({ address: peerAddress, writeOnly = false }) { 19 | // N.B. writeOnly is used when creating a peer instance for a non-peer 20 | // receiver, which does not occur in this example. 21 | const stream = new Duplex({ 22 | objectMode: true, 23 | read () {}, 24 | write: (message, _, done) => { 25 | console.log(chalk.cyan(`${this.fromAddress.serverId} --> ${peerAddress.serverId}`), message) 26 | process.nextTick(() => inputWritters.get(peerAddress.serverId)(message)) 27 | done() 28 | } 29 | }) 30 | inputWritters.set(this.fromAddress.serverId, message => stream.push(message)) 31 | 32 | return stream 33 | } 34 | } 35 | 36 | const cluster = new Map(['alice', 'bob'].map(serverId => { 37 | const server = createServer({ 38 | address: `in-memory:///${serverId}`, 39 | electionTimeoutWindow: [5000, 6000], 40 | heartbeatInterval: 2500, 41 | createTransport (fromAddress) { return new Transport(fromAddress) }, 42 | persistState (state) { 43 | console.log(`${chalk.red(serverId)} persistState`, state) 44 | }, 45 | persistEntries (entries) { 46 | console.log(`${chalk.red(serverId)} persistEntries`, entries) 47 | }, 48 | applyEntry (entry) { 49 | console.log(`${chalk.red(serverId)} applyEntry`, entry) 50 | return 'oh hai!' 51 | }, 52 | crashHandler (err) { 53 | console.error(`${chalk.red(serverId)} crashed!`, err && err.stack || err) 54 | } 55 | }) 56 | server.on('candidate', () => console.log(`${chalk.red(serverId)} is candidate`)) 57 | server.on('follower', () => console.log(`${chalk.red(serverId)} is follower`)) 58 | server.on('leader', () => console.log(`${chalk.red(serverId)} is leader`)) 59 | 60 | return [server.address, server] 61 | })) 62 | 63 | for (const [address, server] of cluster) { 64 | const joinAddresses = Array.from(cluster.keys()).filter(peerAddress => peerAddress !== address) 65 | server.join(joinAddresses) 66 | .then(() => console.log(`${chalk.red(server.id)} joined`)) 67 | .catch(err => console.error(`${chalk.red(server.id)} listen error`, err && err.stack || err)) 68 | } 69 | 70 | Promise.race( 71 | Array.from(cluster, ([, server]) => { 72 | return new Promise(resolve => server.once('leader', () => resolve(server))) 73 | }) 74 | ).then(leader => { 75 | return leader.append('hello world').then(result => console.log(`${chalk.red(leader.id)} append result`, result)) 76 | }).catch(err => console.error(err && err.stack || err)) 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "buoyant", 3 | "version": "0.3.0", 4 | "description": "Framework for implementing servers using the Raft Consensus Algorithm", 5 | "keywords": [ 6 | "raft", 7 | "consensus" 8 | ], 9 | "author": "Mark Wubben (https://novemberborn.net/)", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/novemberborn/buoyant" 13 | }, 14 | "homepage": "https://github.com/novemberborn/buoyant", 15 | "bugs": "https://github.com/novemberborn/buoyant/issues", 16 | "license": "ISC", 17 | "engines": { 18 | "node": ">=6.2.2" 19 | }, 20 | "main": "dist/lib/main.js", 21 | "files": [ 22 | "dist/lib", 23 | "src/lib" 24 | ], 25 | "scripts": { 26 | "prepublish": "npm run build", 27 | "clean": "rimraf dist coverage", 28 | "prebuild": "npm run clean", 29 | "build": "babel src --out-dir dist --source-maps", 30 | "precoverage": "npm run clean", 31 | "coverage": "npm run build -- --plugins __coverage__ && nyc npm test", 32 | "example": "node scripts/example.js", 33 | "fuzz": "node scripts/run.js", 34 | "lint": "as-i-preach", 35 | "test": "ava", 36 | "posttest": "npm run lint", 37 | "watch:build": "npm run build -- --watch", 38 | "watch:test": "npm run test -- --watch" 39 | }, 40 | "devDependencies": { 41 | "@novemberborn/as-i-preach": "^3.1.1", 42 | "ava": "^0.15.2", 43 | "babel-cli": "^6.10.1", 44 | "babel-core": "^6.9.1", 45 | "babel-plugin-__coverage__": "^11.0.0", 46 | "babel-plugin-module-map": "^1.0.1", 47 | "babel-plugin-transform-es2015-modules-commonjs": "^6.10.3", 48 | "babel-plugin-transform-strict-mode": "^6.8.0", 49 | "chalk": "^1.1.3", 50 | "es6-error": "^3.0.0", 51 | "lolex": "^1.5.0", 52 | "metasyntactic-variables": "^1.0.0", 53 | "nyc": "^6.6.1", 54 | "pirates": "^2.1.1", 55 | "proxyquire": "^1.7.9", 56 | "rimraf": "^2.5.2", 57 | "sinon": "^1.17.4", 58 | "source-map-support": "^0.4.0" 59 | }, 60 | "ava": { 61 | "babel": { 62 | "plugins": [ 63 | [ 64 | "babel-plugin-module-map", 65 | { 66 | "dist": "./dist" 67 | } 68 | ], 69 | "transform-async-to-generator", 70 | "transform-es2015-modules-commonjs", 71 | "transform-strict-mode" 72 | ] 73 | }, 74 | "files": "test/**/*.js", 75 | "source": [ 76 | "package.json", 77 | "dist/lib/**/*.js" 78 | ] 79 | }, 80 | "babel": { 81 | "comments": false, 82 | "compact": false, 83 | "plugins": [ 84 | "transform-async-to-generator", 85 | "transform-es2015-modules-commonjs", 86 | "transform-strict-mode" 87 | ] 88 | }, 89 | "nyc": { 90 | "instrument": false, 91 | "reporter": [ 92 | "lcov", 93 | "html", 94 | "text" 95 | ], 96 | "sourceMap": false 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /scripts/example.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | 5 | const babel = require('babel-core') 6 | const sourceMapSupport = require('source-map-support') 7 | 8 | // Resolve the example location relative to the current working directory. 9 | const mod = path.resolve('example', process.argv[2]) 10 | 11 | // Fix up argv so the example thinks it was invoked directly. Don't fix other 12 | // properties like execArgv. 13 | process.argv = ['node', mod].concat(process.argv.slice(3)) 14 | 15 | // Cache source maps for the example modules that were transformed on the fly. 16 | const transformMaps = Object.create(null) 17 | 18 | // Hook up source map support to rewrite stack traces. Use cached source maps 19 | // but fall back to retrieving them from the pragma in the source file. The 20 | // latter will work for `npm run build` output. 21 | sourceMapSupport.install({ 22 | environment: 'node', 23 | handleUncaughtExceptions: false, 24 | retrieveSourceMap (source) { 25 | return transformMaps[source] 26 | } 27 | }) 28 | 29 | // Only modules in the example dir are transformed. All other modules are 30 | // assumed to be compatible. This means the examples run with the build code 31 | // as it's distributed on npm. 32 | const exampleDir = path.dirname(mod) + '/' 33 | require('pirates').addHook(function (code, filename) { 34 | const result = babel.transform(code, Object.assign({ 35 | ast: false, 36 | filename, 37 | sourceMap: true 38 | })) 39 | transformMaps[filename] = { url: filename, map: result.map } 40 | return result.code 41 | }, { 42 | matcher: filename => filename.startsWith(exampleDir) 43 | }) 44 | 45 | // Now load the example. 46 | require(mod) 47 | -------------------------------------------------------------------------------- /scripts/run.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Hook up source map support to rewrite stack traces. 4 | require('source-map-support').install({ 5 | environment: 'node', 6 | handleUncaughtExceptions: false 7 | }) 8 | 9 | require('../dist/fuzz/run') 10 | -------------------------------------------------------------------------------- /src/fuzz/Configuration.js: -------------------------------------------------------------------------------- 1 | import metasyntacticVariables from 'metasyntactic-variables' 2 | 3 | import Address from '../lib/Address' 4 | import exposeEvents from '../lib/expose-events' 5 | 6 | import intInRange from './int-in-range' 7 | import Network from './Network' 8 | import pickRandom from './pick-random' 9 | import Simulation from './Simulation' 10 | import QueueManager from './QueueManager' 11 | 12 | const idPrefixes = metasyntacticVariables.filter(prefix => { 13 | // Reject prefixes that look too similar to others (bar and qux respectively). 14 | return prefix !== 'baz' && prefix !== 'quux' 15 | }) 16 | 17 | function sequentialServerId (seq) { 18 | let id = idPrefixes[seq % idPrefixes.length] 19 | if (seq >= idPrefixes.length) { 20 | id += Math.floor(seq / idPrefixes.length) + 1 21 | } 22 | return id 23 | } 24 | 25 | export default class Configuration { 26 | constructor ({ 27 | clusterSize, 28 | electionTimeoutWindow = [150, 300], 29 | heartbeatInterval = 30, 30 | maxClockSkew = 500, 31 | reporter 32 | }) { 33 | this.addresses = Array.from({ length: clusterSize }, (_, seq) => new Address(`///${sequentialServerId(seq)}`)) 34 | Object.assign(this, { electionTimeoutWindow, heartbeatInterval, maxClockSkew, reporter }) 35 | 36 | const emitter = exposeEvents(this) 37 | this.emit = emitter.emit.bind(emitter) 38 | 39 | this.latestTime = { sinceEpoch: 0, vector: 0 } 40 | this.queueManager = new QueueManager() 41 | this.simulations = new Map() 42 | 43 | this.network = new Network({ 44 | enqueueDelivery: this.queueManager.enqueueDelivery.bind(this.queueManager), 45 | getSimulation: this.getSimulation.bind(this), 46 | reporter 47 | }) 48 | 49 | for (const address of this.addresses) { 50 | this.simulations.set(address, this.createSimulation(address)) 51 | } 52 | } 53 | 54 | createSimulation (address, restartOptions = null) { 55 | const { 56 | electionTimeoutWindow, 57 | emit, 58 | heartbeatInterval, 59 | network, 60 | queueManager, 61 | reporter 62 | } = this 63 | 64 | const electionTimeout = intInRange(electionTimeoutWindow) 65 | if (restartOptions) { 66 | reporter.restartServer(address, heartbeatInterval, electionTimeout) 67 | } else { 68 | reporter.createServer(address, heartbeatInterval, electionTimeout) 69 | } 70 | 71 | return new Simulation(Object.assign({ 72 | address, 73 | createTransport: network.createTransport.bind(network), 74 | electionTimeout, 75 | heartbeatInterval, 76 | queueManager, 77 | reportStateChange: (type, args) => emit('stateChange', { address, args, type }) 78 | }, restartOptions)) 79 | } 80 | 81 | getSimulation (address) { 82 | return this.simulations.get(address) || null 83 | } 84 | 85 | getRandomSimulation () { 86 | const alive = Array.from(this.simulations.values()) 87 | return pickRandom(alive) || null 88 | } 89 | 90 | getEarliestSimulation () { 91 | let earliest = null 92 | let earliestTime = { sinceEpoch: Infinity, vector: Infinity } 93 | for (const simulation of this.simulations.values()) { 94 | const time = simulation.currentTime 95 | if (!earliest || time.sinceEpoch < earliestTime.sinceEpoch) { 96 | [earliest, earliestTime] = [simulation, time] 97 | } else if (time.sinceEpoch === earliestTime.sinceEpoch) { 98 | if (time.vector < earliestTime.vector) { 99 | [earliest, earliestTime] = [simulation, time] 100 | } else if (time.vector === earliestTime.vector && Math.random() < 0.5) { 101 | [earliest, earliestTime] = [simulation, time] 102 | } 103 | } 104 | } 105 | 106 | return earliest 107 | } 108 | 109 | getTrailingSimulation () { 110 | const sorted = Array.from(this.simulations.values(), 111 | simulation => { 112 | return { simulation, time: simulation.currentTime } 113 | }) 114 | .sort(({ time: a }, { time: b }) => { 115 | if (a.sinceEpoch < b.sinceEpoch) { 116 | return -1 117 | } else if (a.sinceEpoch === b.sinceEpoch) { 118 | if (a.vector < b.vector) { 119 | return -1 120 | } else if (a.vector === b.vector && Math.random() < 0.5) { 121 | return -1 122 | } 123 | } 124 | return 1 125 | }) 126 | 127 | const last = sorted.pop() 128 | const exceededMaxSkew = sorted.find(({ time }) => last.time.sinceEpoch - time.sinceEpoch > this.maxClockSkew) 129 | return exceededMaxSkew ? exceededMaxSkew.simulation : this.getRandomSimulation() 130 | } 131 | 132 | hasActiveSimulations () { 133 | return this.simulations.size > 0 134 | } 135 | 136 | getLogs () { 137 | return Array.from(this.simulations, ([address, { log }]) => ({ address, log })) 138 | } 139 | 140 | joinInitialCluster () { 141 | return Promise.all(this.addresses.map(address => { 142 | const cluster = this.addresses.filter(other => other !== address) 143 | return this.getSimulation(address).joinInitialCluster(cluster) 144 | })) 145 | } 146 | 147 | advanceClock (simulation) { 148 | this.reporter.advanceClock(simulation) 149 | 150 | const time = simulation.advanceClock() 151 | const { sinceEpoch, vector } = this.latestTime 152 | if (time.sinceEpoch > sinceEpoch || time.sinceEpoch === sinceEpoch && time.vector > vector) { 153 | this.latestTime = time 154 | } 155 | } 156 | 157 | advanceTrailingClock () { 158 | const simulation = this.getTrailingSimulation() 159 | if (!simulation) { 160 | throw new Error('Cannot advance trailing clock simulation, no active simulations are remaining') 161 | } 162 | 163 | this.advanceClock(simulation) 164 | } 165 | 166 | triggerElection () { 167 | let earliest = null 168 | let earliestTime = Infinity 169 | for (const [, simulation] of this.simulations) { 170 | const { electionTimeout } = simulation 171 | if (!earliest || electionTimeout < earliestTime) { 172 | [earliest, earliestTime] = [simulation, electionTimeout] 173 | } 174 | } 175 | 176 | this.advanceClock(earliest) 177 | return earliest 178 | } 179 | 180 | async killRandomSimulation () { 181 | const simulation = this.getRandomSimulation() 182 | if (!simulation) { 183 | throw new Error('Cannot kill simulation, no active simulations are remaining') 184 | } 185 | 186 | return this.killSimulation(simulation.address) 187 | } 188 | 189 | async killSimulation (address, afterCrash = false) { 190 | const simulation = this.simulations.get(address) 191 | if (!simulation) { 192 | throw new Error('Cannot kill simulation that is already dead') 193 | } 194 | 195 | if (afterCrash) { 196 | this.reporter.killAfterCrash(simulation) 197 | } else { 198 | this.reporter.kill(simulation) 199 | } 200 | 201 | await simulation.destroy() 202 | this.simulations.delete(address) 203 | this.queueManager.delete(address) 204 | return simulation 205 | } 206 | 207 | async restartSimulation (address, restoreEntries, restoreLastApplied, restoreState, time) { 208 | if (this.simulations.has(address)) { 209 | throw new Error('Cannot restart simulation that is already active') 210 | } 211 | 212 | // Start the new simulation at the earliest time of the remaining 213 | // simulations, or after the last time seen in the configuration if there 214 | // are no remaining simulations. 215 | const earliest = this.getEarliestSimulation() 216 | const { sinceEpoch: epoch } = earliest ? earliest.currentTime : this.latestTime 217 | const { vector } = time 218 | const simulation = this.createSimulation(address, { 219 | restoreEntries, 220 | restoreLastApplied, 221 | restoreState, 222 | time: { epoch, vector } 223 | }) 224 | this.simulations.set(address, simulation) 225 | 226 | // Advance the clock so the new simulation isn't actually the earliest when 227 | // it joins the cluster. 228 | this.advanceClock(simulation) 229 | 230 | const cluster = this.addresses.filter(other => other !== address) 231 | await simulation.joinInitialCluster(cluster) 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /src/fuzz/Network.js: -------------------------------------------------------------------------------- 1 | import Entry from '../lib/Entry' 2 | import { 3 | AppendEntries, 4 | AcceptEntries, 5 | RejectEntries, 6 | RequestVote, 7 | DenyVote, 8 | GrantVote, 9 | Noop 10 | } from '../lib/symbols' 11 | 12 | import pickRandom from './pick-random' 13 | 14 | class Node { 15 | constructor (address) { 16 | Object.assign(this, { address }) 17 | 18 | this.partitionId = 0 19 | this.streams = new Map() 20 | } 21 | } 22 | 23 | const TypeSerialization = { 24 | [AppendEntries]: 'AppendEntries', 25 | [AcceptEntries]: 'AcceptEntries', 26 | [RejectEntries]: 'RejectEntries', 27 | [RequestVote]: 'RequestVote', 28 | [DenyVote]: 'DenyVote', 29 | [GrantVote]: 'GrantVote', 30 | [Noop]: 'Noop' 31 | } 32 | 33 | const TypeDeserialization = { 34 | AppendEntries, 35 | AcceptEntries, 36 | RejectEntries, 37 | RequestVote, 38 | DenyVote, 39 | GrantVote, 40 | Noop 41 | } 42 | 43 | function serialize (message) { 44 | const { entries, type } = message 45 | const shallow = Object.assign({}, message, { type: TypeSerialization[type] }) 46 | if (type === AppendEntries) { 47 | shallow.entries = entries.map(({ index, term, value }) => { 48 | if (value === Noop) { 49 | return { index, noop: true, term } 50 | } 51 | return { index, term, value } 52 | }) 53 | } 54 | return shallow 55 | } 56 | 57 | function deserialize (obj) { 58 | const { entries, type } = obj 59 | const message = Object.assign({}, obj, { type: TypeDeserialization[type] }) 60 | if (message.type === AppendEntries) { 61 | message.entries = entries.map(({ index, noop, term, value }) => { 62 | if (noop) { 63 | return new Entry(index, term, Noop) 64 | } 65 | return new Entry(index, term, value) 66 | }) 67 | } 68 | return message 69 | } 70 | 71 | export default class Network { 72 | constructor ({ enqueueDelivery, getSimulation, reporter }) { 73 | Object.assign(this, { enqueueDelivery, getSimulation, reporter }) 74 | 75 | this.nodes = new Map() 76 | } 77 | 78 | createTransport (serverAddress) { 79 | const node = new Node(serverAddress) 80 | this.nodes.set(serverAddress, node) 81 | 82 | const network = this 83 | const simulation = this.getSimulation(serverAddress) 84 | return { 85 | listen () { 86 | // Mimick a stream for messages from outside of the cluster, which 87 | // aren't yet supported by the fuzzer. 88 | return { 89 | once () {}, 90 | read () { return null } 91 | } 92 | }, 93 | 94 | connect ({ address: peerAddress, writeOnly = false }) { 95 | // Implement the minimal stream interface required by the message 96 | // buffer. 97 | const messages = [] 98 | let onReadable = null 99 | const stream = { 100 | once (_, cb) { 101 | onReadable = cb 102 | }, 103 | 104 | read () { 105 | return messages.shift() || null 106 | }, 107 | 108 | write (message) { 109 | const delivery = network.enqueueDelivery(peerAddress, serverAddress, simulation.currentTime, serialize(message)) 110 | network.reporter.sendMessage(simulation, peerAddress, delivery) 111 | }, 112 | 113 | push (message) { 114 | messages.push(message) 115 | if (onReadable) { 116 | onReadable() 117 | onReadable = null 118 | } 119 | } 120 | } 121 | 122 | if (!writeOnly) { 123 | node.streams.set(peerAddress, stream) 124 | } 125 | 126 | return stream 127 | }, 128 | 129 | destroy () { 130 | network.nodes.delete(serverAddress) 131 | } 132 | } 133 | } 134 | 135 | getPartitions () { 136 | const map = new Map() 137 | for (const node of this.nodes.values()) { 138 | const { partitionId } = node 139 | if (!map.has(partitionId)) { 140 | map.set(partitionId, [node]) 141 | } else { 142 | map.get(partitionId).push(node) 143 | } 144 | } 145 | return Array.from(map.values()) 146 | } 147 | 148 | partition () { 149 | const partitions = this.getPartitions() 150 | const partitionable = partitions.filter(nodes => nodes.length > 1) 151 | if (partitionable.length === 0) return 152 | 153 | const maxPartitionId = partitions.reduce((id, [{ partitionId }]) => Math.max(id, partitionId), 0) 154 | const nodes = pickRandom(partitionable) 155 | const node = pickRandom(nodes) 156 | node.partitionId = maxPartitionId + 1 157 | 158 | this.reporter.partition(this.getPartitions()) 159 | } 160 | 161 | undoPartition () { 162 | const partitions = this.getPartitions() 163 | if (partitions.length <= 1) return 164 | 165 | const from = pickRandom(partitions) 166 | const node = pickRandom(from) 167 | const [{ partitionId }] = pickRandom(partitions.filter(nodes => nodes !== from)) 168 | node.partitionId = partitionId 169 | 170 | this.reporter.undoPartition(this.getPartitions()) 171 | } 172 | 173 | deliver (receiver, delivery) { 174 | const { message, sender } = delivery 175 | const receiverNode = this.nodes.get(receiver) 176 | if (!receiverNode.streams.has(sender)) throw new Error(`No connection from ${sender} to receiver ${receiver}`) 177 | 178 | // FIXME: Perhaps store the sender's partitionId when the delivery is 179 | // enqueued? That way it can delivered if the receiver is still in the same 180 | // partition even if the sender has since been destroyed. 181 | const senderNode = this.nodes.get(sender) 182 | if (!senderNode || !receiverNode) return 183 | 184 | // Drop message if nodes are in different partitions 185 | if (receiverNode.partitionId !== senderNode.partitionId) return 186 | 187 | this.reporter.receiveMessage(this.getSimulation(receiver), delivery) 188 | 189 | const stream = receiverNode.streams.get(sender) 190 | stream.push(deserialize(message)) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /src/fuzz/QueueManager.js: -------------------------------------------------------------------------------- 1 | export const ApplyEntry = Symbol('ApplyEntry') 2 | export const PersistEntries = Symbol('PersistEntries') 3 | export const PersistState = Symbol('PersistState') 4 | 5 | export default class QueueManager { 6 | constructor () { 7 | this.idCount = 0 8 | this.queuesByAddress = new Map() 9 | } 10 | 11 | getQueues (address) { 12 | if (!this.queuesByAddress.has(address)) { 13 | this.queuesByAddress.set(address, { 14 | calls: [], 15 | deliveries: [] 16 | }) 17 | } 18 | 19 | return this.queuesByAddress.get(address) 20 | } 21 | 22 | enqueue (queue, item) { 23 | Object.assign(item, { id: this.idCount++, queued: 1 }) 24 | item.promise = new Promise((resolve, reject) => { 25 | Object.assign(item, { resolve, reject }) 26 | }) 27 | 28 | queue.push(item) 29 | return item 30 | } 31 | 32 | enqueueCall (address, type, timestamp, args) { 33 | const { calls: queue } = this.getQueues(address) 34 | return this.enqueue(queue, { 35 | args, 36 | timestamp, 37 | type 38 | }) 39 | } 40 | 41 | enqueueDelivery (address, sender, timestamp, message) { 42 | const { deliveries: queue } = this.getQueues(address) 43 | return this.enqueue(queue, { 44 | message, 45 | sender, 46 | timestamp 47 | }) 48 | } 49 | 50 | dequeue (queue) { 51 | if (queue.length === 0) return null 52 | 53 | const item = queue.shift() 54 | return Object.assign({ 55 | requeue: (position = Math.floor(Math.random() * (queue.length + 1))) => { 56 | item.queued++ 57 | queue.splice(position, 0, item) 58 | } 59 | }, item) 60 | } 61 | 62 | dequeueCall (address) { 63 | const { calls: queue } = this.getQueues(address) 64 | return this.dequeue(queue) 65 | } 66 | 67 | dequeueDelivery (address) { 68 | const { deliveries: queue } = this.getQueues(address) 69 | return this.dequeue(queue) 70 | } 71 | 72 | hasQueuedCalls (address) { 73 | const { calls: queue } = this.getQueues(address) 74 | return queue.length > 0 75 | } 76 | 77 | hasQueuedDeliveries (address) { 78 | const { deliveries: queue } = this.getQueues(address) 79 | return queue.length > 0 80 | } 81 | 82 | delete (address) { 83 | this.queuesByAddress.delete(address) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/fuzz/Reporter.js: -------------------------------------------------------------------------------- 1 | import { inspect } from 'util' 2 | 3 | import { Noop } from '../lib/symbols' 4 | 5 | import { 6 | ApplyEntry, 7 | PersistEntries, 8 | PersistState 9 | } from './QueueManager' 10 | 11 | const CallTypeSerialization = { 12 | [ApplyEntry]: 'applyEntry', 13 | [PersistEntries]: 'persistEntries', 14 | [PersistState]: 'persistState' 15 | } 16 | 17 | export default class Reporter { 18 | constructor ({ colors = true, json = false }) { 19 | Object.assign(this, { colors, json }) 20 | } 21 | 22 | write (record) { 23 | if (this.json) { 24 | console.log(JSON.stringify(record)) 25 | } else { 26 | console.log(inspect(record, { colors: this.colors, depth: null })) 27 | } 28 | } 29 | 30 | async observeRun (promise) { 31 | try { 32 | await promise 33 | this.write({ complete: true }) 34 | } catch (err) { 35 | this.write(Object.assign({ failed: true }, this.serializeError(err))) 36 | console.error(err && err.stack || err) 37 | process.exitCode = 1 38 | } 39 | } 40 | 41 | unhandledRejection (err) { 42 | this.write(Object.assign({ unhandledRejection: true }, this.serializeError(err))) 43 | console.error(err && err.stack || err) 44 | process.exitCode = 1 45 | } 46 | 47 | serializeError (err) { 48 | if (!err) return { err } 49 | 50 | const result = {} 51 | const { message, stack } = err 52 | if (message) result.message = message 53 | if (stack) result.stack = stack 54 | return Object.assign(result, err) 55 | } 56 | 57 | serializePartitions (partitions) { 58 | return partitions.reduce((byId, nodes) => { 59 | const [{ partitionId: id }] = nodes 60 | byId[id] = nodes.map(({ address: { serverId } }) => serverId) 61 | return byId 62 | }, {}) 63 | } 64 | 65 | serializeCall ({ args, id, queued, timestamp, type }) { 66 | const result = { 67 | queuedAt: timestamp, 68 | callId: id, 69 | callType: CallTypeSerialization[type] 70 | } 71 | if (queued > 1) { 72 | result.requeueCount = queued - 1 73 | } 74 | 75 | switch (type) { 76 | case ApplyEntry: { 77 | const [{ index, term, value }] = args 78 | if (value === Noop) { 79 | result.entry = { index, term, noop: true } 80 | } else { 81 | result.entry = { index, term, value } 82 | } 83 | break 84 | } 85 | 86 | case PersistEntries: { 87 | const [entries] = args 88 | result.entries = entries.map(({ index, term, value }) => { 89 | if (value === Noop) { 90 | return { index, term, noop: true } 91 | } 92 | return { index, term, value } 93 | }) 94 | break 95 | } 96 | 97 | case PersistState: { 98 | const [{ currentTerm, votedFor }] = args 99 | Object.assign(result, { currentTerm, votedFor }) 100 | break 101 | } 102 | } 103 | 104 | return result 105 | } 106 | 107 | serializeDelivery ({ id, message, queued, timestamp, type }) { 108 | const result = { 109 | queuedAt: timestamp, 110 | messageId: id, 111 | message 112 | } 113 | if (queued > 1) { 114 | result.requeueCount = queued - 1 115 | } 116 | return result 117 | } 118 | 119 | advanceClock ({ address, currentTime, nextTime }) { 120 | this.write({ 121 | cause: 'advanceClock', 122 | serverId: address.serverId, 123 | serverTime: currentTime, 124 | nextTime 125 | }) 126 | } 127 | 128 | append ({ address, currentTime }, value) { 129 | this.write({ 130 | cause: 'append', 131 | serverId: address.serverId, 132 | serverTime: currentTime, 133 | value 134 | }) 135 | } 136 | 137 | applyEntry ({ address, currentTime }, call) { 138 | this.write(Object.assign({ 139 | effect: 'applyEntry', 140 | serverId: address.serverId, 141 | serverTime: currentTime 142 | }, this.serializeCall(call))) 143 | } 144 | 145 | becameCandidate ({ address, currentTime }) { 146 | this.write({ 147 | effect: 'becameCandidate', 148 | serverId: address.serverId, 149 | serverTime: currentTime 150 | }) 151 | } 152 | 153 | becameFollower ({ address, currentTime }) { 154 | this.write({ 155 | effect: 'becameFollower', 156 | serverId: address.serverId, 157 | serverTime: currentTime 158 | }) 159 | } 160 | 161 | becameLeader ({ address, currentTime }, term) { 162 | this.write({ 163 | effect: 'becameLeader', 164 | serverId: address.serverId, 165 | serverTime: currentTime, 166 | term 167 | }) 168 | } 169 | 170 | commit ({ address, currentTime }, sum) { 171 | this.write({ 172 | effect: 'commit', 173 | serverId: address.serverId, 174 | serverTime: currentTime, 175 | sum 176 | }) 177 | } 178 | 179 | crash ({ address, currentTime }, err) { 180 | this.write({ 181 | effect: 'crash', 182 | serverId: address.serverId, 183 | serverTime: currentTime, 184 | err: this.serializeError(err) 185 | }) 186 | } 187 | 188 | createServer (address, heartbeatInterval, electionTimeout) { 189 | this.write({ 190 | cause: 'createServer', 191 | serverId: address.serverId, 192 | heartbeatInterval, 193 | electionTimeout 194 | }) 195 | } 196 | 197 | dropMessage ({ address, currentTime }, delivery) { 198 | const { sender } = delivery 199 | this.write(Object.assign({ 200 | cause: 'dropMessage', 201 | serverId: address.serverId, 202 | serverTime: currentTime, 203 | senderId: sender.serverId 204 | }, this.serializeDelivery(delivery))) 205 | } 206 | 207 | failCall ({ address, currentTime }, call) { 208 | this.write(Object.assign({ 209 | cause: 'failCall', 210 | serverId: address.serverId, 211 | serverTime: currentTime 212 | }, this.serializeCall(call))) 213 | } 214 | 215 | intentionalCrash ({ address, currentTime }) { 216 | this.write(Object.assign({ 217 | effect: 'intentionalCrash', 218 | serverId: address.serverId, 219 | serverTime: currentTime 220 | })) 221 | } 222 | 223 | kill ({ address, currentTime }) { 224 | this.write({ 225 | cause: 'kill', 226 | serverId: address.serverId, 227 | serverTime: currentTime 228 | }) 229 | } 230 | 231 | killAfterCrash ({ address, currentTime }) { 232 | this.write({ 233 | effect: 'killAfterCrash', 234 | serverId: address.serverId, 235 | serverTime: currentTime 236 | }) 237 | } 238 | 239 | noCommit ({ address, currentTime }, value) { 240 | this.write({ 241 | effect: 'noCommit', 242 | serverId: address.serverId, 243 | serverTime: currentTime, 244 | value 245 | }) 246 | } 247 | 248 | partition (partitions) { 249 | this.write({ 250 | cause: 'partition', 251 | partitions: this.serializePartitions(partitions) 252 | }) 253 | } 254 | 255 | persistEntries ({ address, currentTime }, call) { 256 | this.write(Object.assign({ 257 | effect: 'persistEntries', 258 | serverId: address.serverId, 259 | serverTime: currentTime 260 | }, this.serializeCall(call))) 261 | } 262 | 263 | persistState ({ address, currentTime }, call) { 264 | this.write(Object.assign({ 265 | effect: 'persistState', 266 | serverId: address.serverId, 267 | serverTime: currentTime 268 | }, this.serializeCall(call))) 269 | } 270 | 271 | receiveMessage ({ address, currentTime }, delivery) { 272 | const { sender } = delivery 273 | this.write(Object.assign({ 274 | effect: 'receiveMessage', 275 | serverId: address.serverId, 276 | serverTime: currentTime, 277 | senderId: sender.serverId 278 | }, this.serializeDelivery(delivery))) 279 | } 280 | 281 | restartServer (address, heartbeatInterval, electionTimeout) { 282 | this.write({ 283 | cause: 'restartServer', 284 | serverId: address.serverId, 285 | heartbeatInterval, 286 | electionTimeout 287 | }) 288 | } 289 | 290 | requeue ({ address, currentTime }, delivery) { 291 | const { sender } = delivery 292 | this.write(Object.assign({ 293 | cause: 'requeue', 294 | serverId: address.serverId, 295 | serverTime: currentTime, 296 | senderId: sender.serverId 297 | }, this.serializeDelivery(delivery))) 298 | } 299 | 300 | sendMessage ({ address, currentTime }, receiver, delivery) { 301 | this.write(Object.assign({ 302 | cause: 'sendMessage', 303 | serverId: address.serverId, 304 | serverTime: currentTime, 305 | receiverId: receiver.serverId 306 | }, this.serializeDelivery(delivery))) 307 | } 308 | 309 | undoPartition (partitions) { 310 | this.write({ 311 | cause: 'undoPartition', 312 | partitions: this.serializePartitions(partitions) 313 | }) 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /src/fuzz/Runner.js: -------------------------------------------------------------------------------- 1 | import intInRange from './int-in-range' 2 | import { 3 | AdvanceClock, 4 | Append, 5 | Deliver, 6 | DeliverAndRequeue, 7 | Drop, 8 | FailCall, 9 | Kill, 10 | Partition, 11 | pickSimulationEvent, 12 | pickWorldEvent, 13 | Requeue, 14 | Restart, 15 | RunSimulation, 16 | CallOrDeliver, 17 | UndoPartition 18 | } from './event-generator' 19 | import pickRandom from './pick-random' 20 | import { 21 | ElectionSafetyViolation, 22 | LeaderAppendOnlyViolation, 23 | LeaderCompletenessViolation, 24 | LogMatchingViolation, 25 | StateMachineSafetyViolation 26 | } from './predicate-violations' 27 | import ServerState from './ServerState' 28 | import { 29 | BecameCandidate, 30 | BecameFollower, 31 | BecameLeader, 32 | Crashed 33 | } from './Simulation' 34 | import { 35 | ApplyEntry, 36 | PersistEntries, 37 | PersistState 38 | } from './QueueManager' 39 | 40 | export default class Runner { 41 | constructor ({ configuration, reporter }) { 42 | const { addresses, network, queueManager } = configuration 43 | Object.assign(this, { configuration, network, queueManager, reporter }) 44 | 45 | this.appliedEntries = new Map() 46 | this.serverStates = addresses.reduce((states, address) => states.set(address, new ServerState(address)), new Map()) 47 | configuration.on('stateChange', evt => this.getState(evt.address).pendingStateChanges.push(evt)) 48 | } 49 | 50 | getState (address) { 51 | return this.serverStates.get(address) 52 | } 53 | 54 | getPeerStates (address) { 55 | return Array.from(this.serverStates.values()).filter(({ address: peerAddress }) => peerAddress !== address) 56 | } 57 | 58 | getSimulation (address) { 59 | return this.configuration.getSimulation(address) 60 | } 61 | 62 | getLeader () { 63 | const [address] = Array.from(this.serverStates) 64 | // Get just the leaders. 65 | .filter(([, state]) => state.isLeader) 66 | // Sort by term, highest first. There may be leaders with an older term, 67 | // but they can be ignored. 68 | .sort(([, { term: a }], [, { term: b }]) => b - a) 69 | .map(([address]) => address) 70 | 71 | return address ? this.getSimulation(address) : null 72 | } 73 | 74 | killServer ({ address, currentTime }) { 75 | this.getState(address).kill(currentTime) 76 | } 77 | 78 | verifyElectionSafety ({ address, currentTime }, term) { 79 | // Find other leaders for the same term. There may be leaders with an older 80 | // term, they won't have realized yet they've been deposed. 81 | const otherLeaders = this.getPeerStates(address) 82 | .filter(({ currentTerm, isLeader }) => isLeader && currentTerm === term) 83 | .map(({ address, currentTerm }) => ({ address, currentTerm })) 84 | 85 | if (otherLeaders.length > 0) { 86 | throw new ElectionSafetyViolation({ 87 | address, 88 | otherLeaders, 89 | term, 90 | timestamp: currentTime 91 | }) 92 | } 93 | } 94 | 95 | verifyLeaderAppendOnly ({ address }, entry, timestamp) { 96 | const { isLeader, lastApplied } = this.getState(address) 97 | if (isLeader && entry.index <= lastApplied) { 98 | throw new LeaderAppendOnlyViolation({ 99 | address, 100 | entry, 101 | lastApplied, 102 | timestamp 103 | }) 104 | } 105 | } 106 | 107 | verifyLeaderCompleteness ({ address, currentTime, log }) { 108 | for (const [index, term] of this.appliedEntries) { 109 | const entry = log.getEntry(index) 110 | if (!entry || entry.term !== term) { 111 | throw new LeaderCompletenessViolation({ 112 | address, 113 | index, 114 | term, 115 | timestamp: currentTime 116 | }) 117 | } 118 | } 119 | } 120 | 121 | verifyLogMatching () { 122 | const { addressLookup, logs } = this.configuration.getLogs().reduce((acc, { address, log }) => { 123 | acc.addressLookup.set(log, address) 124 | acc.logs.push(log) 125 | return acc 126 | }, { addressLookup: new Map(), logs: [] }) 127 | 128 | // Shortest log first, excluding empty ones. 129 | const nonEmptyLogs = logs.filter(log => log.lastIndex > 0).sort((a, b) => b.lastIndex - a.lastIndex) 130 | 131 | const findMatchIndex = (log1, log2) => { 132 | for (let index = log1.lastIndex; index > 0; index--) { 133 | const entry1 = log1.getEntry(index) 134 | const entry2 = log2.getEntry(index) 135 | if (entry1 && entry2 && entry1.term === entry2.term) { 136 | return index 137 | } 138 | } 139 | 140 | return 0 141 | } 142 | 143 | const describeLog = (log, entry) => { 144 | const address = addressLookup.get(log) 145 | const { currentTime: timestamp } = this.getSimulation(address) 146 | return { address, timestamp, entry } 147 | } 148 | 149 | while (nonEmptyLogs.length > 1) { 150 | const log1 = nonEmptyLogs.shift() 151 | const log2 = nonEmptyLogs[0] 152 | 153 | for (let index = findMatchIndex(log1, log2); index > 0; index--) { 154 | const entry1 = log1.getEntry(index) 155 | const entry2 = log2.getEntry(index) 156 | if (entry1 && !entry2 || !entry1 && entry2 || entry1.term !== entry2.term) { 157 | throw new LogMatchingViolation({ 158 | index, 159 | first: describeLog(log1, entry1), 160 | second: describeLog(log2, entry2) 161 | }) 162 | } 163 | } 164 | } 165 | } 166 | 167 | verifyStateMachineSafety ({ address }, entry, timestamp) { 168 | const peersWithEntry = this.getPeerStates(address) 169 | .filter(({ commitLog }) => commitLog.has(entry.index)) 170 | 171 | for (const { address: peerAddress, commitLog } of peersWithEntry) { 172 | const existingTerm = commitLog.get(entry.index) 173 | if (entry.term !== existingTerm) { 174 | throw new StateMachineSafetyViolation({ 175 | address, 176 | timestamp, 177 | entry, 178 | peer: { 179 | address: peerAddress, 180 | term: existingTerm 181 | } 182 | }) 183 | } 184 | } 185 | } 186 | 187 | async run () { 188 | await this.configuration.joinInitialCluster() 189 | const simulation = this.configuration.triggerElection() 190 | 191 | await new Promise((resolve, reject) => this.loop(simulation, resolve, reject)) 192 | } 193 | 194 | async loop (simulation, done, fail) { 195 | if (!this.configuration.hasActiveSimulations()) return done() 196 | 197 | if (!simulation) { 198 | const action = pickWorldEvent() 199 | if (action === RunSimulation) { 200 | simulation = this.configuration.getTrailingSimulation() 201 | this.loop(simulation, done, fail) 202 | return 203 | } 204 | 205 | try { 206 | await this[action]() 207 | setTimeout(() => this.loop(null, done, fail), 10) 208 | } catch (err) { 209 | fail(err) 210 | } 211 | 212 | return 213 | } 214 | 215 | try { 216 | const { address } = simulation 217 | const state = this.getState(address) 218 | if (state.pendingStateChanges.length > 0) { 219 | const { args, type } = state.pendingStateChanges.shift() 220 | await this[type](simulation, ...args) 221 | } else { 222 | const action = pickSimulationEvent() 223 | await this[action](simulation) 224 | this.verifyLogMatching() 225 | } 226 | 227 | if ( 228 | state.killed || 229 | ( 230 | !state.pendingStateChanges.length && 231 | !this.queueManager.hasQueuedDeliveries(address) && 232 | !this.queueManager.hasQueuedCalls(address) && 233 | simulation.isIdle 234 | ) 235 | ) { 236 | setTimeout(() => this.loop(null, done, fail), 10) 237 | } else { 238 | setTimeout(() => this.loop(simulation, done, fail), 10) 239 | } 240 | } catch (err) { 241 | fail(err) 242 | } 243 | } 244 | 245 | makeDelivery (simulation, delivery, requeue) { 246 | this.network.deliver(simulation.address, delivery) 247 | 248 | if (requeue) { 249 | this.reporter.requeue(simulation, delivery) 250 | delivery.requeue() 251 | } 252 | } 253 | 254 | // World events 255 | [Append] () { 256 | const leader = this.getLeader() 257 | if (leader) { 258 | const value = intInRange([-100, 100]) 259 | this.reporter.append(leader, value) 260 | leader.append(value) 261 | .then(sum => this.reporter.commit(leader, sum)) 262 | .catch(() => this.reporter.noCommit(leader, value)) 263 | } 264 | } 265 | 266 | async [Kill] () { 267 | const simulation = await this.configuration.killRandomSimulation() 268 | this.killServer(simulation) 269 | } 270 | 271 | [Partition] () { 272 | this.network.partition() 273 | } 274 | 275 | async [Restart] () { 276 | const killed = Array.from(this.serverStates) 277 | .filter(([, state]) => state.killed) 278 | .map(([address]) => address) 279 | 280 | if (killed.length > 0) { 281 | const address = pickRandom(killed) 282 | const { restoreEntries, restoreLastApplied, restoreState, time } = this.getState(address).restart() 283 | await this.configuration.restartSimulation(address, restoreEntries, restoreLastApplied, restoreState, time) 284 | } 285 | } 286 | 287 | [UndoPartition] () { 288 | this.network.undoPartition() 289 | } 290 | 291 | // State changes 292 | [BecameCandidate] (simulation) { 293 | this.reporter.becameCandidate(simulation) 294 | this.getState(simulation.address).changeRole('candidate') 295 | } 296 | 297 | [BecameFollower] (simulation) { 298 | this.reporter.becameFollower(simulation) 299 | this.getState(simulation.address).changeRole('follower') 300 | } 301 | 302 | [BecameLeader] (simulation, term) { 303 | const { address } = simulation 304 | this.reporter.becameLeader(simulation, term) 305 | this.getState(address).changeRole('leader') 306 | 307 | this.verifyElectionSafety(simulation, term) 308 | this.verifyLeaderCompleteness(simulation) 309 | } 310 | 311 | async [Crashed] (simulation, err) { 312 | if (err === FailCall) { 313 | const { address } = simulation 314 | this.reporter.intentionalCrash(simulation) 315 | 316 | await this.configuration.killSimulation(address, true) 317 | this.killServer(simulation) 318 | } else { 319 | this.reporter.crash(simulation, err) 320 | throw err 321 | } 322 | } 323 | 324 | // Simulation events 325 | [AdvanceClock] () { 326 | this.configuration.advanceTrailingClock() 327 | } 328 | 329 | [CallOrDeliver] (simulation) { 330 | const { address } = simulation 331 | if (this.queueManager.hasQueuedCalls(address)) { 332 | const call = this.queueManager.dequeueCall(address) 333 | this[call.type](simulation, call) 334 | } else { 335 | this[Deliver](simulation) 336 | } 337 | } 338 | 339 | [Deliver] (simulation) { 340 | const { address } = simulation 341 | if (this.queueManager.hasQueuedDeliveries(address)) { 342 | this.makeDelivery(simulation, this.queueManager.dequeueDelivery(address), false) 343 | } 344 | } 345 | 346 | [DeliverAndRequeue] (simulation) { 347 | const { address } = simulation 348 | if (this.queueManager.hasQueuedDeliveries(address)) { 349 | this.makeDelivery(simulation, this.queueManager.dequeueDelivery(address), true) 350 | } 351 | } 352 | 353 | [Drop] (simulation) { 354 | const { address } = simulation 355 | if (this.queueManager.hasQueuedDeliveries(address)) { 356 | const delivery = this.queueManager.dequeueDelivery(address) 357 | this.reporter.dropMessage(simulation, delivery) 358 | } 359 | } 360 | 361 | [FailCall] (simulation) { 362 | const { address } = simulation 363 | if (this.queueManager.hasQueuedCalls(address)) { 364 | const call = this.queueManager.dequeueCall(address) 365 | this.reporter.failCall(simulation, call) 366 | call.reject(FailCall) 367 | } 368 | } 369 | 370 | [Requeue] (simulation) { 371 | const { address } = simulation 372 | if (this.queueManager.hasQueuedDeliveries(address)) { 373 | const delivery = this.queueManager.dequeueDelivery(address) 374 | this.reporter.requeue(simulation, delivery) 375 | delivery.requeue() 376 | } 377 | } 378 | 379 | // Call handlers 380 | [ApplyEntry] (simulation, call) { 381 | const { address } = simulation 382 | const { args: [entry], timestamp } = call 383 | this.reporter.applyEntry(simulation, call) 384 | 385 | this.verifyLeaderAppendOnly(simulation, entry, timestamp) 386 | 387 | this.appliedEntries.set(entry.index, entry.term) 388 | const sum = this.getState(address).applyEntry(entry) 389 | 390 | this.verifyStateMachineSafety(simulation, entry, timestamp) 391 | call.resolve(sum) 392 | } 393 | 394 | [PersistEntries] (simulation, call) { 395 | const { args: [entries] } = call 396 | this.reporter.persistEntries(simulation, call) 397 | this.getState(simulation.address).persistEntries(entries) 398 | call.resolve() 399 | } 400 | 401 | [PersistState] (simulation, call) { 402 | const { args: [{ currentTerm, votedFor }] } = call 403 | this.reporter.persistState(simulation, call) 404 | this.getState(simulation.address).persistState(currentTerm, votedFor) 405 | call.resolve() 406 | } 407 | } 408 | -------------------------------------------------------------------------------- /src/fuzz/ServerState.js: -------------------------------------------------------------------------------- 1 | export default class ServerState { 2 | constructor (address) { 3 | this.address = address 4 | this.commitLog = new Map() 5 | this.currentTerm = null 6 | this.entries = [] 7 | this.indexes = new Set() 8 | this.killed = false 9 | this.killTime = null 10 | this.lastApplied = 0 11 | this.pendingStateChanges = [] 12 | this.role = null 13 | this.state = {} 14 | this.sum = 0 15 | this.votedFor = null 16 | } 17 | 18 | applyEntry (entry) { 19 | this.commitLog.set(entry.index, entry.term) 20 | this.lastApplied = entry.index 21 | this.sum += entry.value 22 | return this.sum 23 | } 24 | 25 | changeRole (role) { 26 | this.role = role 27 | } 28 | 29 | get isLeader () { 30 | return this.role === 'leader' 31 | } 32 | 33 | kill (time) { 34 | this.killed = true 35 | this.killTime = time 36 | this.pendingStateChanges = [] 37 | this.role = null 38 | } 39 | 40 | persistEntries (entries) { 41 | let overwrittenOrDeleted = 0 42 | 43 | for (const entry of entries) { 44 | for (let ix = entry.index; this.indexes.has(ix); ix++) { 45 | this.indexes.delete(ix) 46 | overwrittenOrDeleted++ 47 | } 48 | 49 | this.entries.push(entry) 50 | this.indexes.add(entry.index) 51 | } 52 | 53 | return overwrittenOrDeleted 54 | } 55 | 56 | persistState (currentTerm, votedFor) { 57 | this.currentTerm = currentTerm 58 | this.votedFor = votedFor 59 | } 60 | 61 | restart () { 62 | const { currentTerm, entries, killTime: time, lastApplied, votedFor } = this 63 | this.killed = false 64 | this.killTime = null 65 | 66 | if (currentTerm === null) { 67 | return { time } 68 | } 69 | 70 | return { 71 | restoreEntries: entries, 72 | restoreLastApplied: lastApplied, 73 | restoreState: { currentTerm, votedFor }, 74 | time 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/fuzz/Simulation.js: -------------------------------------------------------------------------------- 1 | import { install as installClock } from 'lolex' 2 | 3 | import Server from '../lib/Server' 4 | 5 | import { 6 | ApplyEntry, 7 | PersistEntries, 8 | PersistState 9 | } from './QueueManager' 10 | 11 | export const Crashed = Symbol('Crashed') 12 | export const BecameCandidate = Symbol('BecameCandidate') 13 | export const BecameFollower = Symbol('BecameFollower') 14 | export const BecameLeader = Symbol('BecameLeader') 15 | 16 | export default class Simulation { 17 | constructor ({ 18 | address, 19 | createTransport, 20 | electionTimeout, 21 | time = { epoch: 0, vector: 0 }, 22 | heartbeatInterval, 23 | reportStateChange, 24 | restoreEntries, 25 | restoreLastApplied, 26 | restoreState, 27 | queueManager 28 | }) { 29 | this.address = address 30 | this.electionTimeout = electionTimeout 31 | 32 | const createStateChangeReporter = type => (...args) => reportStateChange(type, args) 33 | const createCallEnqueuer = type => (...args) => queueManager.enqueueCall(address, type, this.currentTime, args).promise 34 | this.server = new Server({ 35 | address, 36 | applyEntry: createCallEnqueuer(ApplyEntry), 37 | crashHandler: createStateChangeReporter(Crashed), 38 | createTransport, 39 | electionTimeoutWindow: [electionTimeout, electionTimeout], 40 | heartbeatInterval, 41 | persistEntries: createCallEnqueuer(PersistEntries), 42 | persistState: createCallEnqueuer(PersistState) 43 | }) 44 | this.server.on('candidate', createStateChangeReporter(BecameCandidate)) 45 | this.server.on('follower', createStateChangeReporter(BecameFollower)) 46 | this.server.on('leader', createStateChangeReporter(BecameLeader)) 47 | 48 | this.raft = this.server._raft 49 | this.log = this.raft.log 50 | 51 | this.clock = installClock(this.raft.timers, time.epoch, ['clearInterval', 'setInterval', 'clearTimeout', 'setTimeout']) 52 | this.vectorClock = time.vector 53 | 54 | this.clockDelta = electionTimeout 55 | this.server.on('candidate', () => { this.clockDelta = electionTimeout }) 56 | this.server.on('follower', () => { this.clockDelta = electionTimeout }) 57 | this.server.on('leader', () => { this.clockDelta = heartbeatInterval }) 58 | 59 | if (restoreState) this.server.restoreState(restoreState) 60 | if (restoreEntries) this.server.restoreLog(restoreEntries, restoreLastApplied) 61 | } 62 | 63 | advanceClock () { 64 | this.vectorClock++ 65 | this.clock.next() 66 | return this.currentTime 67 | } 68 | 69 | append (value) { 70 | return this.server.append(value) 71 | } 72 | 73 | get isIdle () { 74 | return !this.raft.currentRole.scheduler.busy 75 | } 76 | 77 | get currentTime () { 78 | const { 79 | clock: { now: sinceEpoch }, 80 | vectorClock: vector 81 | } = this 82 | return { sinceEpoch, vector } 83 | } 84 | 85 | get nextTime () { 86 | let { 87 | clock: { now: sinceEpoch }, 88 | vectorClock: vector 89 | } = this 90 | sinceEpoch += this.clockDelta 91 | vector += 1 92 | return { sinceEpoch, vector } 93 | } 94 | 95 | destroy () { 96 | return this.server.destroy() 97 | } 98 | 99 | joinInitialCluster (addresses) { 100 | return this.server.join(addresses) 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/fuzz/event-generator.js: -------------------------------------------------------------------------------- 1 | export const Append = Symbol('Append') 2 | export const Kill = Symbol('Kill') 3 | export const Partition = Symbol('Partition') 4 | export const Restart = Symbol('Restart') 5 | export const RunSimulation = Symbol('RunSimulation') 6 | export const UndoPartition = Symbol('UndoPartition') 7 | 8 | export const AdvanceClock = Symbol('AdvanceClock') 9 | export const CallOrDeliver = Symbol('CallOrDeliver') 10 | export const Deliver = Symbol('Deliver') 11 | export const DeliverAndRequeue = Symbol('DeliverAndRequeue') 12 | export const Drop = Symbol('Drop') 13 | export const Requeue = Symbol('Requeue') 14 | export const FailCall = Symbol('FailCall') 15 | 16 | const WorldWeights = { 17 | [Append]: 8, 18 | [Kill]: 1, 19 | [Partition]: 1, 20 | [Restart]: 10, 21 | [RunSimulation]: 70, 22 | [UndoPartition]: 10 23 | // TODO Send messages from non-peers 24 | } 25 | 26 | const SimulationWeights = { 27 | [AdvanceClock]: 50, 28 | [CallOrDeliver]: 724, 29 | [Deliver]: 100, 30 | [DeliverAndRequeue]: 50, 31 | [Drop]: 10, 32 | [Requeue]: 100, 33 | [FailCall]: 1 34 | } 35 | 36 | const WorldEvents = Object.getOwnPropertySymbols(WorldWeights) 37 | export function pickWorldEvent () { 38 | return pick(WorldEvents, WorldWeights) 39 | } 40 | 41 | const SimulationEvents = Object.getOwnPropertySymbols(SimulationWeights) 42 | export function pickSimulationEvent () { 43 | return pick(SimulationEvents, SimulationWeights) 44 | } 45 | 46 | // Weighted random selection 47 | function pick (events, weights) { 48 | let totalWeight = 0 49 | let selected = null 50 | for (const event of events) { 51 | const weight = weights[event] 52 | totalWeight += weight 53 | if (Math.random() * totalWeight < weight) { 54 | selected = event 55 | } 56 | } 57 | return selected 58 | } 59 | -------------------------------------------------------------------------------- /src/fuzz/int-in-range.js: -------------------------------------------------------------------------------- 1 | export default function intInRange (range) { 2 | const [min, max] = range 3 | const diff = max - min 4 | return (Math.random() * diff >> 0) + min 5 | } 6 | -------------------------------------------------------------------------------- /src/fuzz/pick-random.js: -------------------------------------------------------------------------------- 1 | export default function pickRandom (arr) { 2 | return arr[Math.floor(Math.random() * arr.length)] 3 | } 4 | -------------------------------------------------------------------------------- /src/fuzz/predicate-violations.js: -------------------------------------------------------------------------------- 1 | import Error from 'es6-error' 2 | 3 | export class ElectionSafetyViolation extends Error { 4 | constructor ({ address, timestamp, term, otherLeaders }) { 5 | super() 6 | Object.assign(this, { address, timestamp, term, otherLeaders }) 7 | } 8 | } 9 | 10 | export class LeaderAppendOnlyViolation extends Error { 11 | constructor ({ address, timestamp, entry, lastApplied }) { 12 | super() 13 | Object.assign(this, { address, timestamp, entry, lastApplied }) 14 | } 15 | } 16 | 17 | export class LeaderCompletenessViolation extends Error { 18 | constructor ({ address, index, term, timestamp }) { 19 | super() 20 | Object.assign(this, { address, index, term, timestamp }) 21 | } 22 | } 23 | 24 | export class LogMatchingViolation extends Error { 25 | constructor ({ index, first, second }) { 26 | super() 27 | Object.assign(this, { first, second }) 28 | } 29 | } 30 | 31 | export class StateMachineSafetyViolation extends Error { 32 | constructor ({ address, timestamp, entry, peer }) { 33 | super() 34 | Object.assign(this, { address, timestamp, entry, peer }) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/fuzz/run.js: -------------------------------------------------------------------------------- 1 | import Configuration from './Configuration' 2 | import Reporter from './Reporter' 3 | import Runner from './Runner' 4 | 5 | const reporter = new Reporter({ 6 | // TODO: Replace basic argument detection with yargs 7 | colors: process.argv.indexOf('--no-color') === -1, 8 | json: process.argv.indexOf('--json') !== -1 9 | }) 10 | 11 | const configuration = new Configuration({ 12 | // TODO: Make the various options configurable via command line arguments 13 | clusterSize: 5, 14 | reporter 15 | }) 16 | 17 | const runner = new Runner({ 18 | configuration, 19 | reporter 20 | }) 21 | 22 | reporter.observeRun(runner.run()) 23 | process.on('unhandledRejection', err => reporter.unhandledRejection(err)) 24 | -------------------------------------------------------------------------------- /src/lib/Address.js: -------------------------------------------------------------------------------- 1 | import { parse, format } from 'url' 2 | 3 | const tag = Symbol.for('buoyant:Address') 4 | 5 | // Locates a server within the Raft cluster. The `protocol`, `hostname` and 6 | // `port` values are optional. They are used by the transport to route messages 7 | // between the servers. `serverId` is required and identifies a particular 8 | // server. Addresses with different `protocol`, `hostname` or `port` values but 9 | // the same `serverId` identify the same server. 10 | // 11 | // Addresses can be parsed from, and serialized to, a URL string. The `serverId` 12 | // will be taken from the pathname (without the leading slash). 13 | export default class Address { 14 | constructor (url) { 15 | if (typeof url !== 'string') { 16 | throw new TypeError(`Parameter 'url' must be a string, not ${typeof url}`) 17 | } 18 | 19 | let { protocol, slashes, hostname, port, pathname: serverId } = parse(url, false, true) 20 | if (!slashes) { 21 | if (protocol) { 22 | throw new TypeError("Parameter 'url' requires protocol to be postfixed by ://") 23 | } else { 24 | throw new TypeError("Parameter 'url' must start with // if no protocol is specified") 25 | } 26 | } 27 | 28 | if (protocol) { 29 | // Normalize protocol by removing the postfixed colon. 30 | protocol = protocol.replace(/:$/, '') 31 | } 32 | if (hostname === '') { 33 | // Normalize empty hostnames to `null` 34 | hostname = null 35 | } 36 | if (port !== null) { 37 | // If the port is not in the URL, or is not an integer, it'll come back as 38 | // null. Otherwise it's a string that will be safe to parse as an integer. 39 | port = Number.parseInt(port, 10) 40 | } 41 | 42 | serverId = (serverId || '').replace(/^\//, '') // Strip leading slash, if any 43 | if (!serverId) { 44 | throw new TypeError('Address must include a server ID') 45 | } 46 | 47 | Object.defineProperties(this, { 48 | protocol: { value: protocol, enumerable: true }, 49 | hostname: { value: hostname, enumerable: true }, 50 | port: { value: port, enumerable: true }, 51 | serverId: { value: serverId, enumerable: true }, 52 | [tag]: { value: true } 53 | }) 54 | } 55 | 56 | static is (obj) { 57 | return obj ? obj[tag] === true : false 58 | } 59 | 60 | toString () { 61 | return format({ 62 | protocol: this.protocol, 63 | slashes: true, 64 | hostname: this.hostname, 65 | port: this.port, 66 | pathname: this.serverId // format() will add the leading slash for the pathname 67 | }) 68 | } 69 | 70 | inspect () { 71 | return `[buoyant:Address ${this}]` 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/lib/Entry.js: -------------------------------------------------------------------------------- 1 | // An entry in the log, holding a value that should be replicated in the cluster 2 | // and applied to the server's state machine. 3 | export default class Entry { 4 | constructor (index, term, value) { 5 | if (!Number.isSafeInteger(index) || index < 1) { 6 | throw new TypeError("Parameter 'index' must be a safe integer, greater or equal than 1") 7 | } 8 | if (!Number.isSafeInteger(term) || term < 1) { 9 | throw new TypeError("Parameter 'term' must be a safe integer, greater or equal than 1") 10 | } 11 | 12 | Object.defineProperties(this, { 13 | index: { value: index, enumerable: true }, 14 | term: { value: term, enumerable: true }, 15 | value: { value: value, enumerable: true } 16 | }) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/InputConsumer.js: -------------------------------------------------------------------------------- 1 | // Consume messages from each peer and finally the `nonPeerReceiver`. Messages 2 | // are passed to `handleMessage()`, via the `scheduler`, with the `peer` as the 3 | // first argument and the message as the second. 4 | // 5 | // If `handleMessage()` returns a value it is assumed to be a promise. Reading 6 | // halts until that promise is fulfilled. Rejection errors are passed to 7 | // `crashHandler` and halt consumption indefinitely. 8 | // 9 | // If no peer, or the `nonPeerReceiver`, has any buffered messages, consumption 10 | // halts until a message becomes available. 11 | // 12 | // If a message is received from `nonPeerReceiver` a peer is created before it 13 | // and the message are passed to `handleMessage()`. 14 | export default class InputConsumer { 15 | constructor ({ 16 | crashHandler, 17 | handleMessage, 18 | nonPeerReceiver, 19 | peers, 20 | scheduler 21 | }) { 22 | this.crashHandler = crashHandler 23 | this.handleMessage = handleMessage 24 | this.nonPeerReceiver = nonPeerReceiver 25 | this.peers = peers 26 | this.scheduler = scheduler 27 | 28 | this.buffers = [nonPeerReceiver.messages].concat(peers.map(peer => peer.messages)) 29 | this.stopped = false 30 | } 31 | 32 | start () { 33 | try { 34 | this.consumeNext() 35 | } catch (err) { 36 | this.crash(err) 37 | } 38 | } 39 | 40 | stop () { 41 | this.stopped = true 42 | } 43 | 44 | crash (err) { 45 | this.stop() 46 | this.crashHandler(err) 47 | } 48 | 49 | consumeNext (startIndex = 0) { 50 | // Consume as many messages from each peer as synchronously possible, then 51 | // wait for more. 52 | // 53 | // Note that synchronous message handling may stop consumption as a 54 | // side-effect. Each time a message is handled `this.stopped` must be 55 | // checked. 56 | 57 | let resumeIndex = 0 58 | let pending = null 59 | 60 | let index = startIndex 61 | let repeat = true 62 | while (!this.stopped && repeat && !pending) { 63 | repeat = false 64 | 65 | do { 66 | const peer = this.peers[index] 67 | index = (index + 1) % this.peers.length 68 | 69 | if (peer.messages.canTake()) { 70 | repeat = true 71 | 72 | // Only take the message when the scheduler runs the function, rather 73 | // than passing it as an argument to `scheduler.asap()`. If the 74 | // scheduler is aborted, e.g. due to a role change, the next role 75 | // should be able to take the message instead. 76 | pending = this.scheduler.asap(null, () => this.handleMessage(peer, peer.messages.take())) 77 | 78 | // When the next message is consumed, after the pending promise is 79 | // fulfilled, start with the next peer to prevent starving it. 80 | if (pending) { 81 | resumeIndex = index 82 | } 83 | } 84 | } while (!this.stopped && !pending && index !== startIndex) 85 | } 86 | 87 | // Consume the next non-peer message, if available. 88 | if (!this.stopped && !pending && this.nonPeerReceiver.messages.canTake()) { 89 | pending = this.scheduler.asap(null, () => this.handleNonPeerMessage(...this.nonPeerReceiver.messages.take())) 90 | } 91 | 92 | if (!this.stopped) { 93 | // Wait for new messages to become available. 94 | if (!pending) { 95 | pending = Promise.race(this.buffers.map(messages => messages.await())) 96 | } 97 | 98 | pending.then(() => this.consumeNext(resumeIndex)).catch(err => this.crash(err)) 99 | } 100 | } 101 | 102 | async handleNonPeerMessage (address, message) { 103 | const peer = await this.nonPeerReceiver.createPeer(address) 104 | if (!this.stopped) { 105 | return this.handleMessage(peer, message) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/lib/Log.js: -------------------------------------------------------------------------------- 1 | import Entry from './Entry' 2 | 3 | // Manages the log for each server. Contains all entries. Responsible for 4 | // persisting new entries and committing them to the state machine. 5 | export default class Log { 6 | constructor ({ applier, persistEntries }) { 7 | this.entries = new Map() 8 | this.lastIndex = 0 9 | this.lastTerm = 0 10 | 11 | this.applier = applier 12 | this.persistEntries = persistEntries 13 | } 14 | 15 | close () { 16 | // Assume calling code already takes care of preventing other log operations 17 | // from occurring. Return a promise for when the last queued entry has been 18 | // applied. 19 | return this.applier.finish() 20 | } 21 | 22 | destroy () { 23 | // Assume calling code already takes care of preventing other log operations 24 | // from occurring. Force the asynchronous log application to stop. 25 | this.applier.destroy() 26 | } 27 | 28 | replace (entries, lastApplied = 0) { 29 | this.applier.reset(lastApplied) 30 | this.entries.clear() 31 | 32 | let last = { index: 0, term: 0 } 33 | for (const entry of entries) { 34 | // Resolve conflicts within the entries so the persistence implementation 35 | // does not have to worry about them. 36 | // 37 | // Assumes entries are ordered correctly. 38 | if (this.entries.has(entry.index)) { 39 | this.deleteConflictingEntries(entry.index) 40 | } 41 | this.entries.set(entry.index, entry) 42 | last = entry 43 | } 44 | 45 | this.lastIndex = last.index 46 | this.lastTerm = last.term 47 | } 48 | 49 | deleteConflictingEntries (fromIndex) { 50 | for (let ix = fromIndex; this.entries.has(ix); ix++) { 51 | this.entries.delete(ix) 52 | } 53 | } 54 | 55 | getTerm (index) { 56 | // Note that the log starts at index 1, so if index 0 is requested then 57 | // return the default term value. 58 | if (index === 0) return 0 59 | 60 | // Get the term of the entry at the given index. Rightfully crash if the 61 | // entry does not exist. 62 | return this.entries.get(index).term 63 | } 64 | 65 | getEntry (index) { 66 | return this.entries.get(index) 67 | } 68 | 69 | getEntriesSince (index) { 70 | const entries = [] 71 | for (let ix = index; ix <= this.lastIndex; ix++) { 72 | entries.push(this.entries.get(ix)) 73 | } 74 | return entries 75 | } 76 | 77 | async appendValue (currentTerm, value) { 78 | const entry = new Entry(this.lastIndex + 1, currentTerm, value) 79 | 80 | await this.persistEntries([entry]) 81 | 82 | // Persistence is asynchronous, yet `lastIndex` is set to the entry's index 83 | // when it's finished. This assumes the calling code does not perform any 84 | // other operations that affect the log. 85 | this.lastIndex = entry.index 86 | this.lastTerm = entry.term 87 | this.entries.set(entry.index, entry) 88 | 89 | return entry 90 | } 91 | 92 | async mergeEntries (entries, prevLogIndex, prevLogTerm) { 93 | if (this.lastIndex > prevLogIndex) { 94 | // The log contains extra entries that are not present on the leader. 95 | // Remove them to achieve convergence. 96 | this.deleteConflictingEntries(prevLogIndex + 1) 97 | this.lastIndex = prevLogIndex 98 | this.lastTerm = prevLogTerm 99 | } 100 | 101 | // Clean up the list of entries before persisting them. Entries that are 102 | // already in the log don't need to be persisted again. 103 | entries = entries.filter(entry => { 104 | return !this.entries.has(entry.index) || this.entries.get(entry.index).term !== entry.term 105 | }) 106 | 107 | if (entries.length === 0) return 108 | 109 | await this.persistEntries(entries) 110 | 111 | // Like `appendValue()` this assumes the calling code does not perform any 112 | // other operations that affect the log. 113 | let last 114 | for (const entry of entries) { 115 | // Overwrite the log if necessary. 116 | if (this.entries.has(entry.index)) { 117 | this.deleteConflictingEntries(entry.index) 118 | } 119 | 120 | this.entries.set(entry.index, entry) 121 | last = entry 122 | } 123 | 124 | this.lastIndex = last.index 125 | this.lastTerm = last.term 126 | } 127 | 128 | commit (index) { 129 | return new Promise(resolve => { 130 | // Make sure any previous entries are being applied. 131 | for (let prevIndex = this.applier.lastQueued + 1; prevIndex < index; prevIndex++) { 132 | this.applier.enqueue(this.getEntry(prevIndex)) 133 | } 134 | 135 | // Add this entry to the queue 136 | this.applier.enqueue(this.getEntry(index), resolve) 137 | }) 138 | } 139 | 140 | checkOutdated (term, index) { 141 | // The other log is outdated if its term is behind this log. 142 | if (term < this.lastTerm) return true 143 | 144 | // The other log is outdated if it's shorter than this log, if terms are 145 | // equal. 146 | return term === this.lastTerm && index < this.lastIndex 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/lib/LogEntryApplier.js: -------------------------------------------------------------------------------- 1 | import { Noop } from './symbols' 2 | 3 | // Applies log entries to the state machine, one at a time. 4 | export default class LogEntryApplier { 5 | constructor ({ applyEntry, crashHandler }) { 6 | this.lastApplied = 0 // index of the entry that was last successfully applied 7 | this.lastQueued = 0 // index of the entry that was last enqueued to be applied 8 | 9 | this.busy = false 10 | this.queue = [] 11 | 12 | this.applyEntry = applyEntry 13 | this.crashHandler = crashHandler 14 | } 15 | 16 | reset (lastApplied) { 17 | if (!Number.isSafeInteger(lastApplied) || lastApplied < 0) { 18 | throw new TypeError('Cannot reset log entry applier: last-applied index must be a safe, non-negative integer') 19 | } 20 | 21 | if (this.busy) { 22 | throw new Error('Cannot reset log entry applier while entries are being applied') 23 | } 24 | 25 | this.lastApplied = lastApplied 26 | this.lastQueued = lastApplied 27 | } 28 | 29 | enqueue (entry, resolve = null) { 30 | this.queue.push([entry, resolve]) 31 | this.lastQueued = entry.index 32 | this.applyNext() 33 | } 34 | 35 | finish () { 36 | if (!this.busy) return Promise.resolve() 37 | 38 | return new Promise(resolve => { 39 | // Append a fake entry to resolve the finish promise once all entries have 40 | // been applied. Assumes no further entries are applied after this one. 41 | this.queue.push([{ index: this.lastQueued, value: Noop }, resolve]) 42 | }) 43 | } 44 | 45 | destroy () { 46 | this.queue = [] 47 | } 48 | 49 | applyNext () { 50 | if (this.busy) return 51 | this.busy = true 52 | 53 | const [entry, resolve] = this.queue.shift() 54 | 55 | const next = result => { 56 | this.lastApplied = entry.index 57 | 58 | // Propagate the result from the state machine. 59 | if (resolve) { 60 | resolve(result) 61 | } 62 | 63 | // Process the next item, if any. 64 | this.busy = false 65 | if (this.queue.length > 0) { 66 | this.applyNext() 67 | } 68 | } 69 | 70 | if (entry.value === Noop) { 71 | // No-ops do not need to be applied. 72 | next(undefined) 73 | } else { 74 | this.applyEntry(entry).then(next).catch(this.crashHandler) 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/lib/MessageBuffer.js: -------------------------------------------------------------------------------- 1 | // Wraps a readable stream to make it easier to see if a message can be taken 2 | // synchronously, and to get a promise if a message needs to be waited for. 3 | export default class MessageBuffer { 4 | constructor (stream) { 5 | this.stream = stream 6 | 7 | this.next = null 8 | this.awaiting = null 9 | } 10 | 11 | take () { 12 | if (this.next !== null) { 13 | const message = this.next 14 | this.next = null 15 | return message 16 | } 17 | 18 | return this.stream.read() 19 | } 20 | 21 | canTake () { 22 | if (this.next === null) { 23 | this.next = this.stream.read() 24 | } 25 | 26 | return this.next !== null 27 | } 28 | 29 | await () { 30 | if (this.awaiting) { 31 | return this.awaiting 32 | } 33 | 34 | this.awaiting = new Promise(resolve => { 35 | if (this.canTake()) { 36 | resolve() 37 | } else { 38 | this.stream.once('readable', resolve) 39 | } 40 | }).then(() => { 41 | this.awaiting = null 42 | return undefined 43 | }) 44 | return this.awaiting 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/NonPeerReceiver.js: -------------------------------------------------------------------------------- 1 | import MessageBuffer from './MessageBuffer' 2 | import Peer from './Peer' 3 | 4 | // Wraps the stream provided by `transport.listen()`. The `InputConsumer` takes 5 | // a receiver instance to consume messages. 6 | export default class NonPeerReceiver { 7 | constructor (stream, connect) { 8 | this.stream = stream 9 | this.connect = connect 10 | 11 | this.messages = new MessageBuffer(stream) 12 | } 13 | 14 | async createPeer (address) { 15 | const stream = await this.connect({ address, writeOnly: true }) 16 | return new Peer(address, stream) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/lib/Peer.js: -------------------------------------------------------------------------------- 1 | import MessageBuffer from './MessageBuffer' 2 | 3 | // Wraps the stream provided by `transport.connect()`. Represents a peer in the 4 | // cluster. 5 | export default class Peer { 6 | constructor (address, stream) { 7 | this.address = address 8 | this.stream = stream 9 | 10 | this.id = address.serverId 11 | this.messages = new MessageBuffer(stream) 12 | } 13 | 14 | send (message) { 15 | this.stream.write(message) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/Raft.js: -------------------------------------------------------------------------------- 1 | import Candidate from './roles/Candidate' 2 | import Follower from './roles/Follower' 3 | import Leader from './roles/Leader' 4 | 5 | import Log from './Log' 6 | import LogEntryApplier from './LogEntryApplier' 7 | import State from './State' 8 | import Timers from './Timers' 9 | 10 | import NonPeerReceiver from './NonPeerReceiver' 11 | import Peer from './Peer' 12 | 13 | function intInRange (range) { 14 | const [min, max] = range 15 | const diff = max - min 16 | return (Math.random() * diff >> 0) + min 17 | } 18 | 19 | // Implements Raft-compliant behavior. 20 | export default class Raft { 21 | constructor ({ 22 | applyEntry, 23 | crashHandler, 24 | electionTimeoutWindow, 25 | emitEvent, 26 | heartbeatInterval, 27 | id, 28 | persistEntries, 29 | persistState 30 | }) { 31 | this.crashHandler = crashHandler 32 | this.electionTimeout = intInRange(electionTimeoutWindow) 33 | this.emitEvent = emitEvent 34 | this.heartbeatInterval = heartbeatInterval 35 | this.id = id 36 | 37 | this.log = new Log({ 38 | applier: new LogEntryApplier({ applyEntry, crashHandler }), 39 | persistEntries 40 | }) 41 | this.state = new State(persistState) 42 | 43 | this.currentRole = null 44 | this.nonPeerReceiver = null 45 | this.peers = null 46 | 47 | this.timers = new Timers() 48 | } 49 | 50 | replaceState (state) { 51 | this.state.replace(state) 52 | } 53 | 54 | replaceLog (entries, lastApplied) { 55 | this.log.replace(entries, lastApplied) 56 | } 57 | 58 | close () { 59 | if (this.currentRole) { 60 | this.currentRole.destroy() 61 | this.currentRole = null 62 | } 63 | 64 | return this.log.close() 65 | } 66 | 67 | destroy () { 68 | if (this.currentRole) { 69 | this.currentRole.destroy() 70 | this.currentRole = null 71 | } 72 | 73 | return this.log.destroy() 74 | } 75 | 76 | async joinInitialCluster ({ addresses, connect, nonPeerStream }) { 77 | let failed = false 78 | 79 | // Attempt to connect to each address in the cluster and instantiate a peer 80 | // when successful. Let errors propagate to the server, which should in turn 81 | // destroy the transport before attempting to rejoin. 82 | const connectingPeers = addresses.map(async address => { 83 | const stream = await connect({ address }) 84 | 85 | // Don't instantiate peers after errors have occured 86 | if (failed) return 87 | 88 | return new Peer(address, stream) 89 | }) 90 | 91 | try { 92 | const peers = await Promise.all(connectingPeers) 93 | // Create a receiver for the non-peer stream, through which messages can 94 | // be received from other servers that are not yet in the cluster. These 95 | // must still be handled. 96 | this.nonPeerReceiver = new NonPeerReceiver(nonPeerStream, connect) 97 | // Set the initial peers if all managed to connect. 98 | this.peers = peers 99 | // Now enter the initial follower state. 100 | this.convertToFollower() 101 | } catch (err) { 102 | failed = true 103 | throw err 104 | } 105 | } 106 | 107 | becomeLeader () { 108 | if (this.currentRole) { 109 | this.currentRole.destroy() 110 | } 111 | 112 | const { crashHandler, heartbeatInterval, log, nonPeerReceiver, peers, state, timers } = this 113 | const role = this.currentRole = new Leader({ 114 | convertToCandidate: this.convertToCandidate.bind(this), 115 | convertToFollower: this.convertToFollower.bind(this), 116 | crashHandler, 117 | heartbeatInterval, 118 | log, 119 | nonPeerReceiver, 120 | peers, 121 | state, 122 | timers 123 | }) 124 | this.currentRole.start() 125 | 126 | // Only emit the event if the leader role is still active. It is possible 127 | // for it to synchronously consume a message that causes it to become a 128 | // follower, or to crash, causing the role to be destroyed before the event 129 | // can be emitted. 130 | if (this.currentRole === role) { 131 | this.emitEvent('leader', this.state.currentTerm) 132 | } 133 | } 134 | 135 | convertToCandidate () { 136 | if (this.currentRole) { 137 | this.currentRole.destroy() 138 | } 139 | 140 | const { crashHandler, electionTimeout, id: ourId, log, nonPeerReceiver, peers, state, timers } = this 141 | const role = this.currentRole = new Candidate({ 142 | becomeLeader: this.becomeLeader.bind(this), 143 | convertToFollower: this.convertToFollower.bind(this), 144 | crashHandler, 145 | electionTimeout, 146 | log, 147 | nonPeerReceiver, 148 | ourId, 149 | peers, 150 | state, 151 | timers 152 | }) 153 | this.currentRole.start() 154 | 155 | // Only emit the event if the candidate role is still active. It is possible 156 | // for it to crash, causing the role to be destroyed before the event can be 157 | // emitted. 158 | if (this.currentRole === role) { 159 | this.emitEvent('candidate', this.state.currentTerm) 160 | } 161 | } 162 | 163 | convertToFollower (replayMessage) { 164 | if (this.currentRole) { 165 | this.currentRole.destroy() 166 | } 167 | 168 | const { crashHandler, electionTimeout, log, nonPeerReceiver, peers, state, timers } = this 169 | const role = this.currentRole = new Follower({ 170 | convertToCandidate: this.convertToCandidate.bind(this), 171 | crashHandler, 172 | electionTimeout, 173 | log, 174 | nonPeerReceiver, 175 | peers, 176 | state, 177 | timers 178 | }) 179 | // The server can convert to follower state based on an incoming message. 180 | // Pass the message along so the follower can "replay" it. 181 | this.currentRole.start(replayMessage) 182 | 183 | // Only emit the event if the follower role is still active. It is possible 184 | // for it to crash, causing the role to be destroyed before the event can be 185 | // emitted. 186 | if (this.currentRole === role) { 187 | this.emitEvent('follower', this.state.currentTerm) 188 | } 189 | } 190 | 191 | append (value) { 192 | if (!this.currentRole || !this.currentRole.append) { 193 | return Promise.reject(new Error('Not leader')) 194 | } 195 | 196 | return this.currentRole.append(value) 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/lib/Scheduler.js: -------------------------------------------------------------------------------- 1 | // Operations that modify the Raft state or log must not interleave with each 2 | // other. These operations are likely to be asynchronous in nature — this is 3 | // Node.js after all! 4 | // 5 | // This module implements gatekeeper to ensure only one such operation is 6 | // active, queueing future operations to be executed when possible. 7 | export default class Scheduler { 8 | constructor (crashHandler) { 9 | this.crashHandler = crashHandler 10 | 11 | this.aborted = false 12 | this.busy = false 13 | this.queue = [] 14 | } 15 | 16 | asap (handleAbort, fn) { 17 | if (this.aborted) { 18 | if (handleAbort) { 19 | handleAbort() 20 | } 21 | // Return a pending promise since the operation will never be executed, 22 | // but neither does it fail. 23 | return new Promise(() => {}) 24 | } 25 | 26 | if (this.busy) { 27 | return new Promise(resolve => this.queue.push([handleAbort, resolve, fn])) 28 | } 29 | 30 | return this.run(fn) 31 | } 32 | 33 | abort () { 34 | this.aborted = true 35 | this.busy = false 36 | 37 | for (const [handleAbort] of this.queue) { 38 | if (handleAbort) { 39 | handleAbort() 40 | } 41 | } 42 | this.queue = null 43 | } 44 | 45 | crash (err) { 46 | this.crashHandler(err) 47 | // Return a pending promise as the error should be handled by the crash 48 | // handler. 49 | return new Promise(() => {}) 50 | } 51 | 52 | run (fn) { 53 | let promise = null 54 | try { 55 | promise = fn() 56 | } catch (err) { 57 | return this.crash(err) 58 | } 59 | 60 | const next = () => { 61 | if (this.aborted || this.queue.length === 0) { 62 | this.busy = false 63 | return 64 | } 65 | 66 | const [, resolve, fn] = this.queue.shift() 67 | resolve(this.run(fn)) 68 | } 69 | 70 | if (!promise) { 71 | return next() 72 | } 73 | 74 | this.busy = true 75 | // Note that the operations can't return results, just that they completed 76 | // successfully. 77 | return promise.then(next).catch(err => this.crash(err)) 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/lib/Server.js: -------------------------------------------------------------------------------- 1 | import exposeEvents from './expose-events' 2 | import Address from './Address' 3 | import Raft from './Raft' 4 | 5 | // Implements a server that uses the Raft Consensus Algorithm. Provides the 6 | // public interface for interacting with the cluster. 7 | export default class Server { 8 | constructor ({ 9 | address, 10 | applyEntry, 11 | crashHandler, 12 | createTransport, 13 | electionTimeoutWindow, 14 | heartbeatInterval, 15 | id, 16 | persistEntries, 17 | persistState 18 | }) { 19 | const emitter = exposeEvents(this) 20 | 21 | const raft = new Raft({ 22 | applyEntry, 23 | crashHandler, 24 | electionTimeoutWindow, 25 | emitEvent: emitter.emit.bind(emitter), 26 | heartbeatInterval, 27 | id, 28 | persistEntries, 29 | persistState 30 | }) 31 | 32 | Object.defineProperties(this, { 33 | address: { value: address, enumerable: true }, 34 | id: { value: id, enumerable: true }, 35 | 36 | _createTransport: { value: createTransport }, 37 | _raft: { value: raft }, 38 | _allowJoin: { value: true, writable: true }, 39 | _allowRestore: { value: true, writable: true }, 40 | _closeInProgress: { value: null, writable: true }, 41 | _transport: { value: null, writable: true } 42 | }) 43 | } 44 | 45 | // Restore persistent server state prior to joining a cluster. 46 | restoreState (state) { 47 | if (!this._allowRestore) { 48 | throw new Error('Restoring state is no longer allowed') 49 | } 50 | 51 | this._raft.replaceState(state) 52 | } 53 | 54 | // Restore the log and the index of the entry that was last applied to the 55 | // state machine, prior to joining a cluster. 56 | restoreLog (entries, lastApplied) { 57 | if (!this._allowRestore) { 58 | throw new Error('Restoring log is no longer allowed') 59 | } 60 | 61 | this._raft.replaceLog(entries, lastApplied) 62 | } 63 | 64 | // Gracefully stop the Raft implementation, allowing it to finish applying 65 | // entries to the state machine. The transport is destroyed right away since 66 | // no new messages should be sent or received. 67 | close () { 68 | if (!this._closeInProgress) { 69 | this._allowJoin = false 70 | 71 | const transport = this._transport 72 | this._transport = null 73 | 74 | this._closeInProgress = Promise.all([ 75 | transport && new Promise(resolve => resolve(transport.destroy())), 76 | this._raft.close() 77 | ]).then(() => undefined) 78 | } 79 | 80 | return this._closeInProgress 81 | } 82 | 83 | // Ungracefully destroy the Raft implementation and the transport. 84 | destroy () { 85 | this._allowJoin = false 86 | 87 | const transport = this._transport 88 | this._transport = null 89 | 90 | this._closeInProgress = Promise.all([ 91 | transport && new Promise(resolve => resolve(transport.destroy())), 92 | this._raft.destroy() 93 | ]).then(() => undefined) 94 | 95 | return this._closeInProgress 96 | } 97 | 98 | // Join a cluster. 99 | async join (addresses = []) { 100 | addresses = Array.from(addresses, item => { 101 | return Address.is(item) ? item : new Address(item) 102 | }) 103 | 104 | if (!this._allowJoin) { 105 | if (this._closeInProgress) { 106 | throw new Error('Server is closed') 107 | } 108 | throw new Error('Joining a cluster is no longer allowed') 109 | } 110 | 111 | this._allowJoin = false 112 | this._allowRestore = false 113 | 114 | // Try joining the cluster. If errors occur try to close the transport and 115 | // cleanup, then rethrow the original error. This should allow the calling 116 | // code to retry, especially if the error came from the provided 117 | // transport. 118 | this._transport = this._createTransport(this.address) 119 | try { 120 | const nonPeerStream = await this._transport.listen() 121 | await this._raft.joinInitialCluster({ 122 | addresses, 123 | connect: async opts => this._transport.connect(opts), 124 | nonPeerStream 125 | }) 126 | } catch (err) { 127 | if (this._closeInProgress) { 128 | throw err 129 | } 130 | 131 | this._allowJoin = true 132 | this._allowRestore = true 133 | 134 | const transport = this._transport 135 | this._transport = null 136 | try { 137 | await transport.destroy() 138 | throw err 139 | } catch (_) { 140 | throw err 141 | } 142 | } 143 | } 144 | 145 | // Append a value to the state machine, once it's been sufficiently replicated 146 | // within the Raft cluster, and only if this server is the leader. 147 | append (value) { 148 | return this._raft.append(value) 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/lib/State.js: -------------------------------------------------------------------------------- 1 | // Manages the persistent `currentTerm` and `votedFor` state of the server. 2 | // Provides methods for changing these values, returning a promise that fulfils 3 | // once the new state has been persisted. 4 | export default class State { 5 | constructor (persist) { 6 | this.currentTerm = 0 7 | this.votedFor = null 8 | 9 | this.persist = async () => { 10 | await persist({ 11 | currentTerm: this.currentTerm, 12 | votedFor: this.votedFor 13 | }) 14 | } 15 | } 16 | 17 | replace ({ currentTerm, votedFor }) { 18 | if (!Number.isSafeInteger(currentTerm) || currentTerm < 0) { 19 | throw new TypeError('Cannot replace state: current term must be a safe, non-negative integer') 20 | } 21 | 22 | this.currentTerm = currentTerm 23 | this.votedFor = votedFor 24 | } 25 | 26 | nextTerm (votedFor = null) { 27 | if (this.currentTerm === Number.MAX_SAFE_INTEGER) { 28 | throw new RangeError('Cannot advance term: it is already the maximum safe integer value') 29 | } 30 | 31 | this.currentTerm++ 32 | this.votedFor = votedFor 33 | return this.persist() 34 | } 35 | 36 | setTerm (term) { 37 | if (!Number.isSafeInteger(term) || term < 1) { 38 | throw new TypeError('Cannot set term: must be a safe integer, greater than or equal to 1') 39 | } 40 | 41 | this.currentTerm = term 42 | this.votedFor = null 43 | return this.persist() 44 | } 45 | 46 | setTermAndVote (term, votedFor) { 47 | if (!Number.isSafeInteger(term) || term < 1) { 48 | throw new TypeError('Cannot set term: must be a safe integer, greater than or equal to 1') 49 | } 50 | 51 | this.currentTerm = term 52 | this.votedFor = votedFor 53 | return this.persist() 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/lib/Timers.js: -------------------------------------------------------------------------------- 1 | // Wraps the global timer methods. Using a class means each server has its own 2 | // instance which can then be stubbed by lolex. This is mostly useful for fuzz 3 | // testing where the clock in each server should advance independently. 4 | export default class Timers { 5 | clearInterval (intervalObject) { 6 | return clearInterval(intervalObject) 7 | } 8 | 9 | setInterval (callback, delay, ...args) { 10 | return setInterval(callback, delay, ...args) 11 | } 12 | 13 | clearTimeout (timeoutObject) { 14 | return clearTimeout(timeoutObject) 15 | } 16 | 17 | setTimeout (callback, delay, ...args) { 18 | return setTimeout(callback, delay, ...args) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/lib/expose-events.js: -------------------------------------------------------------------------------- 1 | // Exposes `on()`, `once()` and `removeListener()` methods on the `target`. 2 | // These are nearly the same as Node's corresponding `EventEmitter` methods, 3 | // except that a listener can only be registered once for the same event. 4 | 5 | // The returned emitter has an `emit()` method that is not exposed on the 6 | // `target`. Events are emitted synchronously, however if a listener throws an 7 | // exception no remaining listeners are invoked. The error is rethrown 8 | // asynchronously. 9 | import process from './process' 10 | 11 | export default function exposeEvents (target) { 12 | const emitter = new Emitter(target) 13 | 14 | Object.defineProperties(target, { 15 | on: { 16 | value (event, listener) { emitter.on(event, listener); return this }, 17 | configurable: true, 18 | enumerable: false, 19 | writable: true 20 | }, 21 | once: { 22 | value (event, listener) { emitter.once(event, listener); return this }, 23 | configurable: true, 24 | enumerable: false, 25 | writable: true 26 | }, 27 | removeListener: { 28 | value (event, listener) { emitter.removeListener(event, listener); return this }, 29 | configurable: true, 30 | enumerable: false, 31 | writable: true 32 | } 33 | }) 34 | 35 | return emitter 36 | } 37 | 38 | class Emitter { 39 | constructor (context) { 40 | this.context = context 41 | this.registry = new Map() 42 | } 43 | 44 | on (event, listener) { 45 | this.addListener(event, listener, false) 46 | } 47 | 48 | once (event, listener) { 49 | this.addListener(event, listener, true) 50 | } 51 | 52 | addListener (event, listener, fireOnce) { 53 | if (typeof listener !== 'function') { 54 | throw new TypeError(`Parameter 'listener' must be a function, not ${typeof listener}`) 55 | } 56 | 57 | if (this.registry.has(event)) { 58 | const listeners = this.registry.get(event) 59 | if (listeners.has(listener)) { 60 | // fireOnce can only be true if the previous registration was supposed 61 | // to fire once. 62 | listeners.set(listener, fireOnce === listeners.get(listener)) 63 | } else { 64 | listeners.set(listener, fireOnce) 65 | } 66 | } else { 67 | this.registry.set(event, new Map().set(listener, fireOnce)) 68 | } 69 | } 70 | 71 | removeListener (event, listener) { 72 | if (typeof listener !== 'function') { 73 | throw new TypeError(`Parameter 'listener' must be a function, not ${typeof listener}`) 74 | } 75 | 76 | const listeners = this.registry.get(event) 77 | if (!listeners) { 78 | return 79 | } 80 | 81 | listeners.delete(listener) 82 | if (!listeners.size) { 83 | this.registry.delete(event) 84 | } 85 | } 86 | 87 | emit (event, ...params) { 88 | const listeners = this.registry.get(event) 89 | if (!listeners) { 90 | return 91 | } 92 | 93 | for (const [listener, fireOnce] of listeners) { 94 | try { 95 | listener.call(this.context, ...params) 96 | } catch (err) { 97 | process.nextTick(() => { throw err }) 98 | break 99 | } finally { 100 | if (fireOnce) { 101 | listeners.delete(listener) 102 | } 103 | } 104 | } 105 | 106 | if (!listeners.size) { 107 | this.registry.delete(event) 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/lib/main.js: -------------------------------------------------------------------------------- 1 | import * as symbols from './symbols' 2 | import Address from './Address' 3 | import Entry from './Entry' 4 | import Server from './Server' 5 | 6 | export { symbols, Address, Entry } 7 | 8 | // Creates the server instance, validating the arguments and wrapping them if 9 | // necessary. The network transport, state persistence and the state machine are 10 | // to be implemented outside of the server and exposed to the server via the 11 | // various parameters. 12 | export function createServer ({ 13 | address, 14 | applyEntry, 15 | crashHandler, 16 | createTransport, 17 | electionTimeoutWindow, 18 | heartbeatInterval, 19 | persistEntries, 20 | persistState 21 | }) { 22 | if (typeof address === 'string') { 23 | address = new Address(address) 24 | } else if (!Address.is(address)) { 25 | throw new TypeError("Parameter 'address' must be a string or an Address instance") 26 | } 27 | 28 | if (typeof applyEntry !== 'function') { 29 | throw new TypeError(`Parameter 'applyEntry' must be a function, not ${typeof applyEntry}`) 30 | } 31 | 32 | if (typeof crashHandler !== 'function') { 33 | throw new TypeError(`Parameter 'crashHandler' must be a function, not ${typeof crashHandler}`) 34 | } 35 | 36 | if (typeof createTransport !== 'function') { 37 | throw new TypeError(`Parameter 'createTransport' must be a function, not ${typeof createTransport}`) 38 | } 39 | 40 | try { 41 | const [first, last] = electionTimeoutWindow 42 | if (!Number.isInteger(first) || !Number.isInteger(last)) { 43 | throw new TypeError("Values of parameter 'electionTimeoutWindow' must be integers") 44 | } 45 | if (first <= 0) { 46 | throw new TypeError("First value of parameter 'electionTimeoutWindow' must be greater than zero") 47 | } 48 | if (first >= last) { 49 | throw new TypeError("Second value of parameter 'electionTimeoutWindow' must be greater than the first") 50 | } 51 | } catch (_) { 52 | throw new TypeError("Parameter 'electionTimeoutWindow' must be iterable") 53 | } 54 | 55 | if (!Number.isInteger(heartbeatInterval) || heartbeatInterval <= 0) { 56 | throw new TypeError("Parameter 'heartbeatInterval' must be an integer, greater than zero") 57 | } 58 | 59 | if (typeof persistEntries !== 'function') { 60 | throw new TypeError(`Parameter 'persistEntries' must be a function, not ${typeof persistEntries}`) 61 | } 62 | 63 | if (typeof persistState !== 'function') { 64 | throw new TypeError(`Parameter 'persistState' must be a function, not ${typeof persistState}`) 65 | } 66 | 67 | return new Server({ 68 | address, 69 | applyEntry: async entry => applyEntry(entry), 70 | crashHandler, 71 | createTransport, 72 | electionTimeoutWindow, 73 | heartbeatInterval, 74 | id: address.serverId, 75 | persistEntries: async entries => persistEntries(entries), 76 | persistState: async state => persistState(state) 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /src/lib/process.js: -------------------------------------------------------------------------------- 1 | // Simply re-exports the process global. Using Proxyquire tests can stub out 2 | // the process object if necessary. 3 | export default process 4 | -------------------------------------------------------------------------------- /src/lib/roles/Candidate.js: -------------------------------------------------------------------------------- 1 | import { 2 | AppendEntries, RejectEntries, 3 | RequestVote, DenyVote, GrantVote 4 | } from '../symbols' 5 | 6 | import InputConsumer from '../InputConsumer' 7 | import Scheduler from '../Scheduler' 8 | 9 | const handlerMap = Object.create(null, { 10 | [RequestVote]: { value: 'handleRequestVote' }, 11 | [GrantVote]: { value: 'handleGrantVote' }, 12 | [AppendEntries]: { value: 'handleAppendEntries' } 13 | }) 14 | 15 | // Implements candidate behavior according to Raft. 16 | export default class Candidate { 17 | constructor ({ 18 | becomeLeader, 19 | convertToFollower, 20 | crashHandler, 21 | electionTimeout, 22 | log, 23 | nonPeerReceiver, 24 | ourId, 25 | peers, 26 | state, 27 | timers 28 | }) { 29 | this.becomeLeader = becomeLeader 30 | this.convertToFollower = convertToFollower 31 | this.electionTimeout = electionTimeout 32 | this.log = log 33 | this.ourId = ourId 34 | this.peers = peers 35 | this.state = state 36 | this.timers = timers 37 | 38 | this.destroyed = false 39 | this.timeoutObject = null 40 | this.votesRequired = 0 41 | this.votesAlreadyReceived = null 42 | 43 | this.scheduler = new Scheduler(crashHandler) 44 | this.inputConsumer = new InputConsumer({ 45 | crashHandler, 46 | handleMessage: (peer, message) => this.handleMessage(peer, message), 47 | nonPeerReceiver, 48 | peers, 49 | scheduler: this.scheduler 50 | }) 51 | } 52 | 53 | start () { 54 | this.requestVote() 55 | 56 | // Start last so it doesn't preempt requesting votes. 57 | this.inputConsumer.start() 58 | } 59 | 60 | destroy () { 61 | this.destroyed = true 62 | this.timers.clearTimeout(this.timeoutObject) 63 | this.inputConsumer.stop() 64 | this.scheduler.abort() 65 | } 66 | 67 | requestVote () { 68 | this.scheduler.asap(null, async () => { 69 | // A majority of votes is required. Note that the candidate votes for 70 | // itself, so at least half of the remaining votes are needed. 71 | this.votesRequired = Math.ceil(this.peers.length / 2) 72 | // Track which peers granted their vote to avoid double-counting. 73 | this.votesAlreadyReceived = new Set() 74 | 75 | await this.state.nextTerm(this.ourId) 76 | if (this.destroyed) return 77 | 78 | for (const peer of this.peers) { 79 | peer.send({ 80 | type: RequestVote, 81 | term: this.state.currentTerm, 82 | lastLogIndex: this.log.lastIndex, 83 | lastLogTerm: this.log.lastTerm 84 | }) 85 | } 86 | 87 | // Start the timer for this election. Intervals aren't used as they 88 | // complicate the logic. As the server isn't expected to remain a 89 | // candidate for very long there shouldn't be too many timers created. 90 | this.timeoutObject = this.timers.setTimeout(() => this.requestVote(), this.electionTimeout) 91 | }) 92 | } 93 | 94 | handleMessage (peer, message) { 95 | const { type, term } = message 96 | 97 | // Convert to follower if the message has a newer term. 98 | if (term > this.state.currentTerm) { 99 | return this.state.setTerm(term).then(() => { 100 | if (this.destroyed) return 101 | 102 | this.convertToFollower([peer, message]) 103 | return 104 | }) 105 | } 106 | 107 | if (handlerMap[type]) return this[handlerMap[type]](peer, term, message) 108 | } 109 | 110 | handleRequestVote (peer, term) { 111 | // Deny the vote if it's for an older term. Reply with the current term so 112 | // the other candidate can update itself. 113 | if (term < this.state.currentTerm) { 114 | peer.send({ 115 | type: DenyVote, 116 | term: this.state.currentTerm 117 | }) 118 | } 119 | } 120 | 121 | handleGrantVote (peer, term) { 122 | // Accept the vote if it's for the current term and from a peer that hasn't 123 | // previously granted its vote. Become leader once a majority has been 124 | // reached. 125 | if (term !== this.state.currentTerm || this.votesAlreadyReceived.has(peer.id)) { 126 | return 127 | } 128 | 129 | this.votesAlreadyReceived.add(peer.id) 130 | this.votesRequired-- 131 | if (this.votesRequired === 0) { 132 | this.becomeLeader() 133 | } 134 | } 135 | 136 | handleAppendEntries (peer, term, message) { 137 | // Convert to follower if the message is from a leader in the current term. 138 | // Reject the entries if they're part of an older term. The peer has already 139 | // been deposed as leader but it just doesn't know it yet. Let it know by 140 | // sending the current term in the reply. 141 | if (term === this.state.currentTerm) { 142 | this.convertToFollower([peer, message]) 143 | } else { 144 | // handleAppendEntries() is never called directly, only via 145 | // handleMessage() which already checks if the term is newer. Thus a 146 | // simple else branch can be used, which also helps with code coverage 147 | // calculations. 148 | peer.send({ 149 | type: RejectEntries, 150 | term: this.state.currentTerm 151 | }) 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/lib/roles/Follower.js: -------------------------------------------------------------------------------- 1 | import { 2 | AppendEntries, RejectEntries, AcceptEntries, 3 | RequestVote, DenyVote, GrantVote 4 | } from '../symbols' 5 | 6 | import InputConsumer from '../InputConsumer' 7 | import Scheduler from '../Scheduler' 8 | 9 | const handlerMap = Object.create(null, { 10 | [RequestVote]: { value: 'handleRequestVote' }, 11 | [AppendEntries]: { value: 'handleAppendEntries' } 12 | }) 13 | 14 | // Implements follower behavior according to Raft. 15 | export default class Follower { 16 | constructor ({ 17 | convertToCandidate, 18 | crashHandler, 19 | electionTimeout, 20 | log, 21 | nonPeerReceiver, 22 | peers, 23 | state, 24 | timers 25 | }) { 26 | this.convertToCandidate = convertToCandidate 27 | this.electionTimeout = electionTimeout 28 | this.log = log 29 | this.state = state 30 | this.timers = timers 31 | 32 | this.commitIndex = 0 33 | this.destroyed = false 34 | this.ignoreNextElectionTimeout = false 35 | this.intervalObject = null 36 | this.scheduledTimeoutHandler = false 37 | 38 | this.scheduler = new Scheduler(crashHandler) 39 | this.inputConsumer = new InputConsumer({ 40 | crashHandler, 41 | handleMessage: (peer, message) => this.handleMessage(peer, message), 42 | nonPeerReceiver, 43 | peers, 44 | scheduler: this.scheduler 45 | }) 46 | } 47 | 48 | start (replayMessage) { 49 | this.intervalObject = this.timers.setInterval(() => this.maybeStartElection(), this.electionTimeout) 50 | 51 | if (replayMessage) { 52 | this.scheduler.asap(null, () => this.handleMessage(...replayMessage)) 53 | } 54 | 55 | // Start last so it doesn't preempt handling the replay message. 56 | this.inputConsumer.start() 57 | } 58 | 59 | destroy () { 60 | this.destroyed = true 61 | this.timers.clearInterval(this.intervalObject) 62 | this.inputConsumer.stop() 63 | this.scheduler.abort() 64 | } 65 | 66 | maybeStartElection () { 67 | // Use the scheduler to avoid interrupting an active operation. However the 68 | // scheduler may be blocked for longer than the election timeout. Ignore 69 | // further invocations until the first timeout has been handled. 70 | if (this.scheduledTimeoutHandler) return 71 | 72 | this.scheduledTimeoutHandler = true 73 | this.scheduler.asap(null, () => { 74 | this.scheduledTimeoutHandler = false 75 | 76 | // Rather than creating a new timer after receiving a message from the 77 | // leader, set a flag to ignore the next election timeout. This should be 78 | // more efficient, although it may cause the follower to delay a bit 79 | // longer before starting a new election. 80 | if (this.ignoreNextElectionTimeout) { 81 | this.ignoreNextElectionTimeout = false 82 | return 83 | } 84 | 85 | // The election timeout is legit, become a candidate. 86 | this.convertToCandidate() 87 | }) 88 | } 89 | 90 | handleMessage (peer, message) { 91 | const { type, term } = message 92 | if (handlerMap[type]) return this[handlerMap[type]](peer, term, message) 93 | } 94 | 95 | handleRequestVote (peer, term, { lastLogIndex, lastLogTerm }) { 96 | // Deny the vote if it's for an older term. Send the current term in the 97 | // reply so the other candidate can update itself. 98 | if (term < this.state.currentTerm) { 99 | peer.send({ 100 | type: DenyVote, 101 | term: this.state.currentTerm 102 | }) 103 | return 104 | } 105 | 106 | // Grant the vote on a first-come first-serve basis, or if the vote was 107 | // already granted to the candidate in the current term. 108 | const allowVote = this.state.votedFor === null || this.state.votedFor === peer.id 109 | // The candidate's log must be up-to-date however. 110 | const outdated = this.log.checkOutdated(lastLogTerm, lastLogIndex) 111 | if (allowVote && !outdated) { 112 | return this.state.setTermAndVote(term, peer.id).then(() => { 113 | if (this.destroyed) return 114 | 115 | // Give the candidate a chance to win the election. 116 | this.ignoreNextElectionTimeout = true 117 | 118 | peer.send({ 119 | type: GrantVote, 120 | term: this.state.currentTerm 121 | }) 122 | 123 | return 124 | }) 125 | } else if (term > this.state.currentTerm) { 126 | // Update the current term if the candidate's is newer, even if no vote 127 | // was granted them. 128 | return this.state.setTerm(term) 129 | } 130 | } 131 | 132 | handleAppendEntries (peer, term, { prevLogIndex, prevLogTerm, entries, leaderCommit }) { 133 | // Reject the entries if they're part of an older term. The peer has already 134 | // been deposed as leader but it just doesn't know it yet. Let it know by 135 | // sending the current term in the reply. 136 | if (term < this.state.currentTerm) { 137 | peer.send({ 138 | type: RejectEntries, 139 | term: this.state.currentTerm 140 | }) 141 | return 142 | } 143 | 144 | // Avoid accidentally deposing the leader. 145 | this.ignoreNextElectionTimeout = true 146 | 147 | // Verify the first entry received can safely be appended to the log. There 148 | // must not be any gaps. An index of 0 implies that the leader is sending 149 | // its first entry so there won't be any gaps. 150 | if (prevLogIndex > 0) { 151 | const prevEntry = this.log.getEntry(prevLogIndex) 152 | if (!prevEntry || prevEntry.term !== prevLogTerm) { 153 | peer.send({ 154 | type: RejectEntries, 155 | term: this.state.currentTerm, 156 | conflictingIndex: Math.min(prevLogIndex, Math.max(this.log.lastIndex, 1)) 157 | }) 158 | return 159 | } 160 | } 161 | 162 | // Merge any entries into the log. 163 | let pending = this.log.mergeEntries(entries, prevLogIndex, prevLogTerm) 164 | // Update the current term if the leader's newer. 165 | if (term > this.state.currentTerm) { 166 | pending = Promise.all([pending, this.state.setTerm(term)]) 167 | } 168 | 169 | // Commit the same entries as the leader. 170 | if (leaderCommit > this.commitIndex) { 171 | this.commitIndex = Math.min(leaderCommit, this.log.lastIndex) 172 | this.log.commit(this.commitIndex) 173 | } 174 | 175 | return pending.then(() => { 176 | if (this.destroyed) return 177 | 178 | peer.send({ 179 | type: AcceptEntries, 180 | term: this.state.currentTerm, 181 | // Include the index of the last entry that was appended to the log. 182 | // Otherwise, since communication is based on message passing, the 183 | // leader can't tell which entries were accepted. 184 | // 185 | // As an extra benefit this allows the transport to deliver fewer 186 | // entries than the leader attempted to send. 187 | lastLogIndex: this.log.lastIndex 188 | }) 189 | 190 | return 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/lib/roles/Leader.js: -------------------------------------------------------------------------------- 1 | import { 2 | AppendEntries, RejectEntries, AcceptEntries, 3 | RequestVote, DenyVote 4 | } from '../symbols' 5 | 6 | import InputConsumer from '../InputConsumer' 7 | import Scheduler from '../Scheduler' 8 | 9 | const handlerMap = Object.create(null, { 10 | [RequestVote]: { value: 'handleRequestVote' }, 11 | [RejectEntries]: { value: 'handleRejectEntries' }, 12 | [AcceptEntries]: { value: 'handleAcceptEntries' } 13 | }) 14 | 15 | // Implements leader behavior according to Raft. 16 | export default class Leader { 17 | constructor ({ 18 | convertToCandidate, 19 | convertToFollower, 20 | crashHandler, 21 | heartbeatInterval, 22 | log, 23 | nonPeerReceiver, 24 | peers, 25 | state, 26 | timers 27 | }) { 28 | this.convertToCandidate = convertToCandidate 29 | this.convertToFollower = convertToFollower 30 | this.heartbeatInterval = heartbeatInterval 31 | this.log = log 32 | this.peers = peers 33 | this.state = state 34 | this.timers = timers 35 | 36 | // Track Raft's `nextIndex` and `matchIndex` values for each peer. 37 | this.peerState = peers.reduce((map, peer) => { 38 | return map.set(peer, { 39 | nextIndex: log.lastIndex + 1, 40 | matchIndex: 0 41 | }) 42 | }, new Map()) 43 | 44 | this.commitIndex = 0 45 | this.destroyed = false 46 | this.pendingApplication = [] 47 | this.scheduledHeartbeatHandler = false 48 | this.skipNextHeartbeat = false 49 | this.intervalObject = null 50 | 51 | this.scheduler = new Scheduler(crashHandler) 52 | this.inputConsumer = new InputConsumer({ 53 | crashHandler, 54 | handleMessage: (peer, message) => this.handleMessage(peer, message), 55 | nonPeerReceiver, 56 | peers, 57 | scheduler: this.scheduler 58 | }) 59 | } 60 | 61 | start () { 62 | this.intervalObject = this.timers.setInterval(() => this.sendHeartbeat(), this.heartbeatInterval) 63 | 64 | // Claim leadership by sending a heartbeat. 65 | this.sendHeartbeat() 66 | 67 | // TODO: Determine if there are uncommitted entries from previous terms. If 68 | // so, append a no-op entry. Once that entry is committed so will the 69 | // previous entries. 70 | 71 | // Start last so it doesn't preempt claiming leadership. 72 | this.inputConsumer.start() 73 | } 74 | 75 | destroy () { 76 | this.destroyed = true 77 | this.timers.clearInterval(this.intervalObject) 78 | this.inputConsumer.stop() 79 | this.scheduler.abort() 80 | for (const { reject } of this.pendingApplication) { 81 | reject(new Error('No longer leader')) 82 | } 83 | this.pendingApplication = [] 84 | } 85 | 86 | sendHeartbeat () { 87 | // Use the scheduler to avoid interrupting an active operation. However the 88 | // scheduler may be blocked for longer than the heartbeat interval. Ignore 89 | // further invocations until the first heartbeat has been sent. 90 | if (this.scheduledHeartbeatHandler) return 91 | 92 | this.scheduledHeartbeatHandler = true 93 | this.scheduler.asap(null, () => { 94 | this.scheduledHeartbeatHandler = false 95 | 96 | // Heartbeats are sent using an interval rather than a timer. This should 97 | // be more efficient. To avoid sending unnecessary heartbeats, a flag is 98 | // set after all followers are updated with an actual entry. 99 | if (this.skipNextHeartbeat) { 100 | this.skipNextHeartbeat = false 101 | return 102 | } 103 | 104 | for (const [peer, state] of this.peerState) { 105 | this.updateFollower(peer, state, true) 106 | } 107 | }) 108 | } 109 | 110 | handleMessage (peer, message) { 111 | const { type, term } = message 112 | 113 | // Convert to follower if the message has a newer term. 114 | if (term > this.state.currentTerm) { 115 | return this.state.setTerm(term).then(() => { 116 | if (this.destroyed) return 117 | 118 | this.convertToFollower([peer, message]) 119 | return 120 | }) 121 | } 122 | 123 | if (handlerMap[type]) return this[handlerMap[type]](peer, term, message) 124 | } 125 | 126 | handleRequestVote (peer, term) { 127 | // Deny the vote if it's for an older term. Send the current term in the 128 | // reply so the candidate can update itself. 129 | if (term < this.state.currentTerm) { 130 | peer.send({ 131 | type: DenyVote, 132 | term: this.state.currentTerm 133 | }) 134 | return 135 | } 136 | 137 | // The leader can ignore vote requests for the current term. Send a 138 | // heartbeat instead, provided the peer is in the cluster. 139 | if (this.peerState.has(peer)) { 140 | this.updateFollower(peer, this.peerState.get(peer), true) 141 | } 142 | } 143 | 144 | handleRejectEntries (peer, term, { conflictingIndex }) { 145 | // Discard messages from servers not in the cluster. 146 | if (!this.peerState.has(peer)) return 147 | 148 | // See if the follower is lagging. Don't let stale replies impact 149 | // convergence with the follower. 150 | const state = this.peerState.get(peer) 151 | if (conflictingIndex > state.matchIndex && conflictingIndex < state.nextIndex) { 152 | state.nextIndex = conflictingIndex 153 | this.updateFollower(peer, state, true) 154 | } 155 | } 156 | 157 | handleAcceptEntries (peer, term, { lastLogIndex }) { 158 | // Discard messages from servers not in the cluster. 159 | if (!this.peerState.has(peer)) return 160 | 161 | // The follower's log has converged. Advance its nextIndex and matchIndex 162 | // state if any entries have been accepted. Mark any replication that has 163 | // occurred. Finally see if there are any new entries that need to be sent. 164 | const state = this.peerState.get(peer) 165 | if (lastLogIndex >= state.nextIndex) { 166 | this.markReplication(state.nextIndex, lastLogIndex) 167 | state.nextIndex = lastLogIndex + 1 168 | state.matchIndex = lastLogIndex 169 | } 170 | 171 | if (state.nextIndex <= this.log.lastIndex) { 172 | this.updateFollower(peer, state, false) 173 | } 174 | } 175 | 176 | markReplication (startIndex, endIndex) { 177 | // Mark entries within the range as having been replicated to another 178 | // follower. Note that the follower's identity is irrelevant. The logic in 179 | // `handleAcceptEntries()` prevents double-counting replication to a 180 | // particular follower. 181 | for (const state of this.pendingApplication) { 182 | if (state.index > endIndex) { 183 | break 184 | } 185 | 186 | // Decrement the counter if the entry was replicated to this follower. 187 | if (state.index >= startIndex) { 188 | state.acceptsRequired-- 189 | } 190 | } 191 | 192 | // Apply each entry that is sufficiently replicated. Assume the log 193 | // takes care of applying one entry at a time, and applying entries from 194 | // previous terms if necessary. 195 | while (this.pendingApplication.length > 0 && this.pendingApplication[0].acceptsRequired === 0) { 196 | const { index, resolve } = this.pendingApplication.shift() 197 | 198 | // Update the commit index. The entry will be applied in the background. 199 | this.commitIndex = index 200 | resolve(this.log.commit(index)) 201 | } 202 | } 203 | 204 | append (value) { 205 | return new Promise((resolve, reject) => { 206 | this.scheduler.asap( 207 | () => reject(new Error('Aborted')), 208 | async () => { 209 | const entry = await this.log.appendValue(this.state.currentTerm, value) 210 | if (this.destroyed) { 211 | reject(new Error('No longer leader')) 212 | return 213 | } 214 | 215 | this.pendingApplication.push({ 216 | index: entry.index, 217 | // The entry must be replicated to the majority of the cluster. It's 218 | // already been persisted by the leader, so at least half of the 219 | // remaining peers need to accept it. 220 | acceptsRequired: Math.ceil(this.peers.length / 2), 221 | resolve, 222 | reject 223 | }) 224 | 225 | for (const [peer, state] of this.peerState) { 226 | this.updateFollower(peer, state, false) 227 | } 228 | this.skipNextHeartbeat = true 229 | } 230 | ) 231 | }) 232 | } 233 | 234 | updateFollower (peer, { nextIndex, matchIndex }, heartbeatOnly) { 235 | const prevLogIndex = nextIndex - 1 236 | 237 | // Send a heartbeat so the logs can converge if the follower is behind. 238 | if (prevLogIndex !== matchIndex) { 239 | heartbeatOnly = true 240 | } 241 | 242 | peer.send({ 243 | type: AppendEntries, 244 | term: this.state.currentTerm, 245 | prevLogIndex: prevLogIndex, 246 | prevLogTerm: this.log.getTerm(prevLogIndex), 247 | leaderCommit: this.commitIndex, 248 | // TODO: Avoid repeatedly sending the same entry if the follower is slow, 249 | // find a way to back-off the retransmissions. 250 | entries: heartbeatOnly ? [] : this.log.getEntriesSince(nextIndex) 251 | }) 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /src/lib/symbols.js: -------------------------------------------------------------------------------- 1 | // Types for the AppendEntries RPC and success / failure replies. 2 | export const AppendEntries = Symbol('AppendEntries') 3 | export const AcceptEntries = Symbol('AcceptEntries') 4 | export const RejectEntries = Symbol('RejectEntries') 5 | 6 | // Types for the RequestVote RPC and success / failure replies. 7 | export const RequestVote = Symbol('RequestVote') 8 | export const DenyVote = Symbol('DenyVote') 9 | export const GrantVote = Symbol('GrantVote') 10 | 11 | // Value of a no-op entry. 12 | export const Noop = Symbol('Noop') 13 | -------------------------------------------------------------------------------- /test/lib/Address.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { stub } from 'sinon' 3 | import Address from 'dist/lib/Address' 4 | import macro from './helpers/macro' 5 | 6 | const throwsTypeError = macro((t, url, message) => { 7 | t.throws(() => new Address(url), TypeError, message) 8 | }, suffix => `throw when constructed with a URL that ${suffix}`) 9 | 10 | const hasFields = macro((t, url, expected) => { 11 | const address = new Address(url) 12 | t.deepEqual(address, expected) 13 | }, (_, url) => `address for ${url} has expected fields`) 14 | 15 | const checkFieldConfiguration = macro((t, field) => { 16 | const address = new Address('protocol://hostname/👾') 17 | const { configurable, enumerable, writable } = Object.getOwnPropertyDescriptor(address, field) 18 | t.false(configurable) 19 | t.true(enumerable) 20 | t.false(writable) 21 | }, (_, field) => `configuration of ${field} field is correct`) 22 | 23 | const testToString = macro((t, url) => { 24 | const address = new Address(url) 25 | t.true(address.toString() === url) 26 | }, (_, url) => `toString() of address with URL ${url} returns the equivalent URL string`) 27 | 28 | test('is not a string', throwsTypeError, undefined, "Parameter 'url' must be a string, not undefined") 29 | test('has a protocol but no slashes', throwsTypeError, 'protocol:hostname/pathname', "Parameter 'url' requires protocol to be postfixed by ://") 30 | test('has no protocol and no slashes', throwsTypeError, 'hostname/pathname', "Parameter 'url' must start with // if no protocol is specified") 31 | test('has no pathname', throwsTypeError, 'protocol://hostname', 'Address must include a server ID') 32 | test('has / as its pathname', throwsTypeError, 'protocol://hostname/', 'Address must include a server ID') 33 | 34 | { 35 | const [protocol, hostname, serverId] = ['protocol', 'hostname', 'serverId'] 36 | test(hasFields, 'protocol://hostname:42/serverId', { protocol, hostname, port: 42, serverId }) 37 | test(hasFields, 'protocol://hostname/serverId', { protocol, hostname, port: null, serverId }) 38 | test(hasFields, '//hostname/serverId', { protocol: null, hostname, port: null, serverId }) 39 | test(hasFields, '///serverId', { protocol: null, hostname: null, port: null, serverId }) 40 | } 41 | 42 | test(checkFieldConfiguration, 'protocol') 43 | test(checkFieldConfiguration, 'hostname') 44 | test(checkFieldConfiguration, 'port') 45 | test(checkFieldConfiguration, 'serverId') 46 | 47 | test(testToString, 'protocol://hostname:42/serverId') 48 | test(testToString, 'protocol://hostname/serverId') 49 | test(testToString, 'protocol:///serverId') 50 | test(testToString, '//hostname:42/serverId') 51 | test(testToString, '//hostname/serverId') 52 | test(testToString, '///serverId') 53 | 54 | test('inspect() returns a string serialization of the address', t => { 55 | const address = new Address('///👾') 56 | stub(address, 'toString').returns('🎈') 57 | t.true(address.inspect() === '[buoyant:Address 🎈]') 58 | }) 59 | 60 | test('is(obj) recognizes Address instances', t => { 61 | t.true(Address.is(new Address('///👾'))) 62 | }) 63 | 64 | test('is(obj) recognizes objects tagged with the buoyant:Address symbol', t => { 65 | t.true(Address.is({ [Symbol.for('buoyant:Address')]: true })) 66 | }) 67 | 68 | test('is(obj) does not recognize other objects', t => { 69 | t.false(Address.is({})) 70 | }) 71 | 72 | test('is(obj) does not recognize the undefined value', t => { 73 | t.false(Address.is(undefined)) 74 | }) 75 | 76 | test('is(obj) does not recognize the null value', t => { 77 | t.false(Address.is(null)) 78 | }) 79 | -------------------------------------------------------------------------------- /test/lib/Candidate.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { spy, stub } from 'sinon' 3 | 4 | import { 5 | AppendEntries, RejectEntries, 6 | RequestVote, DenyVote, GrantVote 7 | } from 'dist/lib/symbols' 8 | 9 | import dist from './helpers/dist' 10 | import fork from './helpers/fork-context' 11 | import { 12 | setupConstructors, 13 | testFollowerConversion, 14 | testInputConsumerDestruction, testInputConsumerInstantiation, testInputConsumerStart, 15 | testMessageHandlerMapping, 16 | testSchedulerDestruction, testSchedulerInstantiation 17 | } from './helpers/role-tests' 18 | import { stubLog, stubMessages, stubPeer, stubState, stubTimers } from './helpers/stub-helpers' 19 | 20 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 21 | const { Promise } = global 22 | 23 | const Candidate = setupConstructors(dist('lib/roles/Candidate')) 24 | 25 | test.beforeEach(t => { 26 | const becomeLeader = stub() 27 | const convertToFollower = stub() 28 | const crashHandler = stub() 29 | const electionTimeout = 10 30 | const log = stubLog() 31 | const nonPeerReceiver = stub({ messages: stubMessages() }) 32 | const ourId = Symbol() 33 | const peers = [stubPeer(), stubPeer(), stubPeer()] 34 | const state = stubState() 35 | const { clock, timers } = stubTimers() 36 | 37 | state._currentTerm.returns(1) 38 | 39 | const candidate = new Candidate({ 40 | becomeLeader, 41 | convertToFollower, 42 | crashHandler, 43 | electionTimeout, 44 | log, 45 | nonPeerReceiver, 46 | ourId, 47 | peers, 48 | state, 49 | timers 50 | }) 51 | 52 | Object.assign(t.context, { 53 | becomeLeader, 54 | candidate, 55 | clock, 56 | convertToFollower, 57 | crashHandler, 58 | electionTimeout, 59 | log, 60 | nonPeerReceiver, 61 | ourId, 62 | peers, 63 | state 64 | }) 65 | }) 66 | 67 | test.afterEach(t => { 68 | const { candidate } = t.context 69 | if (!candidate.destroyed) candidate.destroy() 70 | }) 71 | 72 | const afterVoteRequest = fork().beforeEach(async t => { 73 | const { candidate, peers, state } = t.context 74 | candidate.requestVote() 75 | state._currentTerm.returns(2) // expect a vote for the second term 76 | await Promise.resolve() 77 | 78 | // There are three peers, so need to receive a vote from two. Seed one 79 | // vote to make the tests easier. 80 | candidate.handleGrantVote(peers[1], 2) 81 | }) 82 | 83 | const afterSecondVoteRequest = afterVoteRequest.fork().beforeEach(async t => { 84 | const { candidate } = t.context 85 | candidate.requestVote() 86 | await Promise.resolve() 87 | }) 88 | 89 | testInputConsumerInstantiation('candidate') 90 | testSchedulerInstantiation('candidate') 91 | 92 | test('start() requests a vote', t => { 93 | const { candidate } = t.context 94 | spy(candidate, 'requestVote') 95 | candidate.start() 96 | t.true(candidate.requestVote.calledOnce) 97 | }) 98 | 99 | testInputConsumerStart('candidate') 100 | 101 | test('destroy() clears the election timer', async t => { 102 | const { candidate, clock, electionTimeout } = t.context 103 | spy(candidate, 'requestVote') // spy on the method called by the timer 104 | 105 | candidate.start() 106 | t.true(candidate.requestVote.calledOnce) // should be called after starting 107 | await Promise.resolve() // wait for the timer to be started 108 | 109 | candidate.destroy() // should prevent the timer from triggering 110 | clock.tick(electionTimeout) // timer should fire now, if not cleared 111 | t.true(candidate.requestVote.calledOnce) // should not be called again 112 | }) 113 | 114 | testInputConsumerDestruction('candidate') 115 | testSchedulerDestruction('candidate') 116 | 117 | test('requestVote() is gated by the scheduler', t => { 118 | const { candidate } = t.context 119 | // Only checks whether the scheduler is used. Not a perfect test since it 120 | // doesn't confirm that the operation is actually gated by the scheduler. 121 | spy(candidate.scheduler, 'asap') 122 | candidate.requestVote() 123 | t.true(candidate.scheduler.asap.calledOnce) 124 | }) 125 | 126 | test('requestVote() advances the term, voting for itself', t => { 127 | const { candidate, ourId, state } = t.context 128 | candidate.requestVote() 129 | t.true(state.nextTerm.calledOnce) 130 | const { args: [id] } = state.nextTerm.firstCall 131 | t.true(id === ourId) 132 | }) 133 | 134 | test('requestVote() does not send RequestVote messages if the candidate was destroyed while persisting the state', async t => { 135 | const { candidate, state, peers: [peer] } = t.context 136 | let persisted 137 | state.nextTerm.returns(new Promise(resolve => { 138 | persisted = resolve 139 | })) 140 | 141 | candidate.requestVote() 142 | candidate.destroy() 143 | persisted() 144 | 145 | await Promise.resolve() 146 | t.true(peer.send.notCalled) 147 | }) 148 | 149 | test('requestVote() does not set the election timer if the candidate was destroyed while persisting the state', async t => { 150 | const { candidate, clock, electionTimeout, state } = t.context 151 | let persisted 152 | state.nextTerm.returns(new Promise(resolve => { 153 | persisted = resolve 154 | })) 155 | 156 | candidate.requestVote() 157 | candidate.destroy() 158 | persisted() 159 | 160 | await Promise.resolve() 161 | spy(candidate, 'requestVote') 162 | clock.tick(electionTimeout) 163 | t.true(candidate.requestVote.notCalled) 164 | }) 165 | 166 | test('requestVote() sends a RequestVote message to each peer', async t => { 167 | const { candidate, log, peers, state } = t.context 168 | const term = Symbol() 169 | state._currentTerm.returns(term) 170 | const [lastLogIndex, lastLogTerm] = [Symbol(), Symbol()] 171 | log._lastIndex.returns(lastLogIndex) 172 | log._lastTerm.returns(lastLogTerm) 173 | 174 | candidate.requestVote() 175 | await Promise.resolve() 176 | 177 | for (const { send } of peers) { 178 | const { args: [message] } = send.firstCall 179 | t.deepEqual(message, { type: RequestVote, term, lastLogIndex, lastLogTerm }) 180 | } 181 | }) 182 | 183 | test('requestVote() requests another vote if the election times out', async t => { 184 | const { candidate, clock, electionTimeout } = t.context 185 | candidate.requestVote() 186 | await Promise.resolve() 187 | 188 | spy(candidate, 'requestVote') 189 | clock.tick(electionTimeout) 190 | t.true(candidate.requestVote.calledOnce) 191 | }) 192 | 193 | testFollowerConversion('candidate') 194 | 195 | testMessageHandlerMapping('candidate', [ 196 | { type: RequestVote, label: 'RequestVote', method: 'handleRequestVote' }, 197 | { type: GrantVote, label: 'GrantVote', method: 'handleGrantVote' }, 198 | { type: AppendEntries, label: 'AppendEntries', method: 'handleAppendEntries' } 199 | ]) 200 | 201 | test('handleRequestVote() sends a DenyVote message to the peer if it sent an older term', t => { 202 | const { candidate, peers: [peer], state } = t.context 203 | state._currentTerm.returns(2) 204 | candidate.handleRequestVote(peer, 1) 205 | 206 | t.true(peer.send.calledOnce) 207 | const { args: [denied] } = peer.send.firstCall 208 | t.deepEqual(denied, { type: DenyVote, term: 2 }) 209 | }) 210 | 211 | test('handleRequestVote() ignores the request if the peer’s term is the same as the current term', t => { 212 | const { candidate, peers: [peer], state } = t.context 213 | state._currentTerm.returns(2) 214 | candidate.handleRequestVote(peer, 2) 215 | 216 | t.true(peer.send.notCalled) 217 | }) 218 | 219 | afterVoteRequest.test('handleGrantVote() disregards votes whose term is not the current one', t => { 220 | const { becomeLeader, candidate, peers } = t.context 221 | candidate.handleGrantVote(peers[0], 1) // outdated term 222 | t.true(becomeLeader.notCalled) 223 | 224 | // The next proper vote should be counted, a majority reached, and the 225 | // candidate becomes leader. 226 | candidate.handleGrantVote(peers[0], 2) 227 | t.true(becomeLeader.calledOnce) 228 | }) 229 | 230 | afterVoteRequest.test('handleGrantVote() disregards repeated votes in the same election', t => { 231 | const { becomeLeader, candidate, peers } = t.context 232 | candidate.handleGrantVote(peers[1], 2) // already voted 233 | t.true(becomeLeader.notCalled) 234 | 235 | // The next proper vote should be counted, a majority reached, and the 236 | // candidate becomes leader. 237 | candidate.handleGrantVote(peers[0], 2) 238 | t.true(becomeLeader.calledOnce) 239 | }) 240 | 241 | afterVoteRequest.test('handleGrantVote() causes the candidate to become leader after receiving votes from at least half of the peers', t => { 242 | const { becomeLeader, candidate, peers } = t.context 243 | candidate.handleGrantVote(peers[0], 2) 244 | t.true(becomeLeader.calledOnce) 245 | }) 246 | 247 | afterSecondVoteRequest.test('handleGrantVote() requires votes from at least half the peers in every election', t => { 248 | const { becomeLeader, candidate, peers } = t.context 249 | candidate.handleGrantVote(peers[0], 2) 250 | t.true(becomeLeader.notCalled) 251 | candidate.handleGrantVote(peers[2], 2) 252 | t.true(becomeLeader.calledOnce) 253 | }) 254 | 255 | afterSecondVoteRequest.test('handleGrantVote() accepts votes from peers that voted in previous elections', t => { 256 | const { becomeLeader, candidate, peers } = t.context 257 | candidate.handleGrantVote(peers[0], 2) 258 | t.true(becomeLeader.notCalled) 259 | // peers[1] voted in the previous election. 260 | candidate.handleGrantVote(peers[1], 2) 261 | t.true(becomeLeader.calledOnce) 262 | }) 263 | 264 | test('handleAppendEntries() causes the candidate to convert to follower if the term is the same as the current term', t => { 265 | const { candidate, convertToFollower, peers: [peer], state } = t.context 266 | state._currentTerm.returns(1) 267 | const message = {} 268 | candidate.handleAppendEntries(peer, 1, message) 269 | 270 | t.true(convertToFollower.calledOnce) 271 | const { args: [replayMessage] } = convertToFollower.firstCall 272 | t.true(replayMessage[0] === peer) 273 | t.true(replayMessage[1] === message) 274 | }) 275 | 276 | test('handleAppendEntries() rejects the entries if the term is older than the current one', t => { 277 | const { candidate, peers: [peer], state } = t.context 278 | state._currentTerm.returns(2) 279 | candidate.handleAppendEntries(peer, 1, {}) 280 | 281 | t.true(peer.send.calledOnce) 282 | const { args: [rejected] } = peer.send.firstCall 283 | t.deepEqual(rejected, { type: RejectEntries, term: 2 }) 284 | }) 285 | -------------------------------------------------------------------------------- /test/lib/Entry.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import Entry from 'dist/lib/Entry' 3 | import macro from './helpers/macro' 4 | 5 | const throwsTypeError = macro((t, values, message) => { 6 | t.throws(() => new Entry(...values), TypeError, message) 7 | }, suffix => `throw when constructed with ${suffix}`) 8 | 9 | const hasField = macro((t, param, expected) => { 10 | const args = [1, 1, '🙊'] 11 | args[{ index: 0, term: 1, value: 2 }[param]] = expected 12 | const entry = new Entry(...args) 13 | t.true(entry[param] === expected) 14 | }, (_, param, value) => `entry constructed with ${param}=${value.toString()} has the correct ${param} field`) 15 | 16 | const checkFieldConfiguration = macro((t, field) => { 17 | const entry = new Entry(1, 1, '🙊') 18 | const { configurable, enumerable, writable } = Object.getOwnPropertyDescriptor(entry, field) 19 | t.false(configurable) 20 | t.true(enumerable) 21 | t.false(writable) 22 | }, (_, field) => `configuration of ${field} field is correct`) 23 | 24 | test('an index that is not an integer', throwsTypeError, ['🙊', 1], "Parameter 'index' must be a safe integer, greater or equal than 1") 25 | test('an index that is not a safe integer', throwsTypeError, [Number.MAX_SAFE_INTEGER + 1, 1], "Parameter 'index' must be a safe integer, greater or equal than 1") 26 | test('an index that is lower than 1', throwsTypeError, [0, 1], "Parameter 'index' must be a safe integer, greater or equal than 1") 27 | test('a term that is an integer', throwsTypeError, [1, '🙊'], "Parameter 'term' must be a safe integer, greater or equal than 1") 28 | test('a term that is not a safe integer', throwsTypeError, [1, Number.MAX_SAFE_INTEGER + 1], "Parameter 'term' must be a safe integer, greater or equal than 1") 29 | test('a term that is lower than 1', throwsTypeError, [1, 0], "Parameter 'term' must be a safe integer, greater or equal than 1") 30 | 31 | test(hasField, 'index', 1) 32 | test(hasField, 'term', 2) 33 | test(hasField, 'value', Symbol()) 34 | 35 | test(checkFieldConfiguration, 'index') 36 | test(checkFieldConfiguration, 'term') 37 | test(checkFieldConfiguration, 'value') 38 | -------------------------------------------------------------------------------- /test/lib/InputConsumer.js: -------------------------------------------------------------------------------- 1 | // https://github.com/avajs/eslint-plugin-ava/issues/127 2 | /* eslint-disable ava/use-t */ 3 | 4 | import test from 'ava' 5 | import { stub } from 'sinon' 6 | 7 | import InputConsumer from 'dist/lib/InputConsumer' 8 | 9 | import fork from './helpers/fork-context' 10 | import macro from './helpers/macro' 11 | import { stubMessages, stubPeer } from './helpers/stub-helpers' 12 | 13 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 14 | const { Promise } = global 15 | 16 | test.beforeEach(t => { 17 | const scheduler = stub({ asap () {} }) 18 | scheduler.asap.returns(new Promise(() => {})) 19 | 20 | const peers = [stubPeer(), stubPeer(), stubPeer()] 21 | 22 | const nonPeerReceiver = stub({ 23 | messages: stubMessages(), 24 | createPeer () {} 25 | }) 26 | nonPeerReceiver.createPeer.returns(new Promise(() => {})) 27 | 28 | const handlers = { 29 | message: stub().returns(new Promise(() => {})), 30 | crash: stub() 31 | } 32 | 33 | const consumer = new InputConsumer({ 34 | peers, 35 | nonPeerReceiver, 36 | scheduler, 37 | handleMessage (...args) { return handlers.message(...args) }, 38 | crashHandler (...args) { return handlers.crash(...args) } 39 | }) 40 | 41 | Object.assign(t.context, { 42 | consumer, 43 | handlers, 44 | nonPeerReceiver, 45 | peers, 46 | scheduler 47 | }) 48 | }) 49 | test.always.afterEach(t => t.context.consumer.stop()) 50 | 51 | const canTakeMessage = fork().beforeEach(t => { 52 | const { peers } = t.context 53 | peers[1].messages.canTake.onCall(0).returns(true) 54 | }) 55 | 56 | const canTakeNonPeerMessage = fork().beforeEach(t => { 57 | const { nonPeerReceiver } = t.context 58 | nonPeerReceiver.messages.canTake.onCall(0).returns(true) 59 | nonPeerReceiver.messages.take.returns([]) 60 | }) 61 | 62 | const crashesConsumer = macro(async (t, setup) => { 63 | const { consumer, handlers } = t.context 64 | 65 | const err = Symbol() 66 | setup(err, t.context) 67 | 68 | consumer.start() 69 | await new Promise(resolve => setImmediate(resolve)) 70 | 71 | t.true(handlers.crash.calledOnce) 72 | const { args: [reason] } = handlers.crash.firstCall 73 | t.true(reason === err) 74 | }, suffix => `crash the consumer ${suffix}`) 75 | 76 | const schedulesMessageHandling = t => { 77 | const { consumer, scheduler } = t.context 78 | consumer.start() 79 | 80 | t.true(scheduler.asap.calledOnce) 81 | const { args: [handleAbort, fn] } = scheduler.asap.firstCall 82 | t.true(handleAbort === null) 83 | t.true(typeof fn === 'function') 84 | } 85 | 86 | const takesMessageWhenHandleable = (t, getPeer) => { 87 | const { consumer, scheduler } = t.context 88 | const peer = getPeer(t.context) 89 | consumer.start() 90 | 91 | t.true(peer.messages.take.notCalled) 92 | scheduler.asap.yield() 93 | t.true(peer.messages.take.calledOnce) 94 | } 95 | 96 | const callsHandleMessage = async (t, setupPeer) => { 97 | const { consumer, handlers, scheduler } = t.context 98 | const message = Symbol() 99 | const peer = setupPeer(t.context, message) 100 | scheduler.asap.onCall(0).yields() 101 | consumer.start() 102 | 103 | // Allows this macro to be used for non-peer messages, which need to wait 104 | // for the peer to be created. 105 | await Promise.resolve() 106 | 107 | t.true(handlers.message.calledOnce) 108 | const { args: [fromPeer, received] } = handlers.message.firstCall 109 | t.true(fromPeer === peer) 110 | t.true(received === message) 111 | } 112 | 113 | test('begin message consumption when start() is called', t => { 114 | const { consumer, peers } = t.context 115 | t.true(peers[0].messages.canTake.notCalled) 116 | 117 | consumer.start() 118 | t.true(peers[0].messages.canTake.calledOnce) 119 | }) 120 | 121 | test('consult each peer in order', t => { 122 | const { peers, consumer } = t.context 123 | consumer.start() 124 | 125 | const stubs = peers.map(peer => peer.messages.canTake) 126 | t.true(stubs[0].calledBefore(stubs[1])) 127 | t.true(stubs[1].calledBefore(stubs[2])) 128 | }) 129 | 130 | canTakeMessage.test('once a message can be taken, schedule its handling', schedulesMessageHandling) 131 | canTakeMessage.test('only take a message when it can be handled', takesMessageWhenHandleable, ({ peers }) => peers[1]) 132 | canTakeMessage.test('pass the peer and the message to handleMessage()', callsHandleMessage, ({ peers }, message) => { 133 | peers[1].messages.take.returns(message) 134 | return peers[1] 135 | }) 136 | 137 | canTakeMessage.test('do not consult further peers when the scheduler returns a promise', t => { 138 | const { consumer, scheduler, peers } = t.context 139 | scheduler.asap.returns(new Promise(() => {})) 140 | consumer.start() 141 | 142 | t.true(peers[2].messages.canTake.notCalled) 143 | }) 144 | 145 | canTakeMessage.test('consult the next peer once the scheduler\'s promise fulfils', async t => { 146 | const { consumer, peers, scheduler } = t.context 147 | 148 | let fulfil 149 | scheduler.asap.onCall(0).returns(new Promise(resolve => { 150 | fulfil = resolve 151 | })) 152 | 153 | consumer.start() 154 | t.true(peers[0].messages.canTake.calledOnce) 155 | t.true(peers[1].messages.canTake.calledOnce) 156 | t.true(peers[2].messages.canTake.notCalled) 157 | 158 | peers[2].messages.canTake.returns(true) 159 | fulfil() 160 | await Promise.resolve() 161 | 162 | t.true(peers[0].messages.canTake.calledOnce) 163 | t.true(peers[1].messages.canTake.calledOnce) 164 | t.true(peers[2].messages.canTake.calledOnce) 165 | }) 166 | 167 | canTakeMessage.test('if the scheduler\'s promise rejects', crashesConsumer, (err, { scheduler }) => { 168 | scheduler.asap.onCall(0).returns(Promise.reject(err)) 169 | }) 170 | 171 | canTakeMessage.test('synchronously consult more peers after a message is handled synchronously', t => { 172 | const { consumer, peers, scheduler } = t.context 173 | scheduler.asap.returns() 174 | peers[0].messages.canTake.onCall(1).returns(true) 175 | consumer.start() 176 | 177 | const calls = [0, 1].reduce((calls, n) => { 178 | return calls.concat(peers.map(peer => peer.messages.canTake.getCall(n))) 179 | }, []).filter(call => call) 180 | 181 | t.true(calls.length === 6) 182 | t.true(calls[0].calledBefore(calls[1])) 183 | t.true(calls[1].calledBefore(calls[2])) 184 | t.true(calls[2].calledBefore(calls[3])) 185 | t.true(calls[3].calledBefore(calls[4])) 186 | t.true(calls[4].calledBefore(calls[5])) 187 | }) 188 | 189 | canTakeMessage.test('if handling a message fails', crashesConsumer, (err, { handlers, peers, scheduler }) => { 190 | handlers.message.throws(err) 191 | peers[0].messages.canTake.onCall(0).returns(true) 192 | peers[0].messages.take.returns(Symbol()) 193 | scheduler.asap.yields() 194 | }) 195 | 196 | test('consult nonPeerReceiver if no message can be taken from the peers', t => { 197 | const { consumer, nonPeerReceiver } = t.context 198 | consumer.start() 199 | t.true(nonPeerReceiver.messages.canTake.calledOnce) 200 | }) 201 | 202 | canTakeNonPeerMessage.test('once a non-peer message can be taken, schedule its handling', schedulesMessageHandling) 203 | canTakeNonPeerMessage.test('only take a non-peer message when it can be handled', takesMessageWhenHandleable, ({ nonPeerReceiver }) => nonPeerReceiver) 204 | 205 | canTakeNonPeerMessage.test('create a peer to handle a non-peer message', t => { 206 | const { consumer, nonPeerReceiver, scheduler } = t.context 207 | const address = Symbol() 208 | nonPeerReceiver.messages.take.returns([address]) 209 | scheduler.asap.onCall(0).yields() 210 | consumer.start() 211 | 212 | t.true(nonPeerReceiver.createPeer.calledOnce) 213 | const { args: [peerAddress] } = nonPeerReceiver.createPeer.firstCall 214 | t.true(peerAddress === address) 215 | }) 216 | 217 | canTakeNonPeerMessage.test('pass the created peer and non-peer message to handleMessage()', callsHandleMessage, ({ nonPeerReceiver }, message) => { 218 | nonPeerReceiver.messages.take.returns([null, message]) 219 | const peer = Symbol() 220 | nonPeerReceiver.createPeer.returns(Promise.resolve(peer)) 221 | return peer 222 | }) 223 | 224 | canTakeNonPeerMessage.test('wait for the non-peer message to be handled before consulting further peers', async t => { 225 | const { consumer, handlers, nonPeerReceiver, peers, scheduler } = t.context 226 | let fulfil 227 | handlers.message.returns(new Promise(resolve => { 228 | fulfil = resolve 229 | })) 230 | nonPeerReceiver.createPeer.returns(Promise.resolve()) 231 | scheduler.asap = (_, fn) => fn() 232 | consumer.start() 233 | 234 | for (const { messages } of peers) { 235 | t.true(messages.await.notCalled) 236 | } 237 | t.true(nonPeerReceiver.messages.await.notCalled) 238 | 239 | fulfil() 240 | await new Promise(resolve => setImmediate(resolve)) 241 | 242 | for (const { messages } of peers) { 243 | t.true(messages.await.calledOnce) 244 | } 245 | t.true(nonPeerReceiver.messages.await.calledOnce) 246 | }) 247 | 248 | canTakeNonPeerMessage.test('if handling the non-peer message fails', crashesConsumer, (err, { handlers, nonPeerReceiver, scheduler }) => { 249 | handlers.message.throws(err) 250 | nonPeerReceiver.createPeer.returns(Promise.resolve()) 251 | scheduler.asap = (_, fn) => fn() 252 | }) 253 | 254 | canTakeNonPeerMessage.test('if creating the peer for the non-peer message fails', crashesConsumer, (err, { nonPeerReceiver, scheduler }) => { 255 | nonPeerReceiver.createPeer.returns(Promise.reject(err)) 256 | scheduler.asap = (_, fn) => fn() 257 | }) 258 | 259 | test('wait for message to become available from any peer or non-peer when no message can be taken otherwise', t => { 260 | const { consumer, nonPeerReceiver, peers } = t.context 261 | consumer.start() 262 | 263 | for (const { messages } of peers) { 264 | t.true(messages.await.calledOnce) 265 | } 266 | t.true(nonPeerReceiver.messages.await.calledOnce) 267 | }) 268 | 269 | test('consult peers after message has become available', async t => { 270 | const { consumer, peers } = t.context 271 | let available 272 | peers[2].messages.await.onCall(0).returns(new Promise(resolve => { 273 | available = resolve 274 | })) 275 | consumer.start() 276 | 277 | // Make a message available for the second peer. 278 | available() 279 | await new Promise(resolve => setImmediate(resolve)) 280 | 281 | // Each canTake() should be invoked, in order (so first peer goes first). 282 | // Ignore the first call to canTake(). 283 | const calls = peers.map(peer => peer.messages.canTake.getCall(1)) 284 | t.true(calls[0].calledBefore(calls[1])) 285 | t.true(calls[1].calledBefore(calls[2])) 286 | }) 287 | 288 | test('if an error occurs while waiting for messages', crashesConsumer, (err, { peers }) => { 289 | peers[2].messages.await.onCall(0).returns(Promise.reject(err)) 290 | }) 291 | 292 | test('stop() prevents messages from being consumed after scheduler finishes', async t => { 293 | const { consumer, peers, scheduler } = t.context 294 | let finish 295 | scheduler.asap.onCall(0).returns(new Promise(resolve => { 296 | finish = resolve 297 | })) 298 | peers[0].messages.canTake.returns(true) 299 | 300 | consumer.start() 301 | consumer.stop() 302 | finish() 303 | await new Promise(resolve => setImmediate(resolve)) 304 | 305 | t.true(peers[1].messages.canTake.notCalled) 306 | }) 307 | 308 | test('stop() prevents messages from being consumed when they become available', async t => { 309 | const { consumer, peers } = t.context 310 | let available 311 | peers[2].messages.await.onCall(0).returns(new Promise(resolve => { 312 | available = resolve 313 | })) 314 | 315 | consumer.start() 316 | consumer.stop() 317 | available() 318 | await new Promise(resolve => setImmediate(resolve)) 319 | 320 | t.true(peers[0].messages.canTake.calledOnce) 321 | }) 322 | 323 | test('stop() immediately prevents messages from being consumed when called as a side-effect from a synchronous handleMessage() call', t => { 324 | const { consumer, handlers, peers, scheduler } = t.context 325 | peers[0].messages.canTake.returns(true) 326 | scheduler.asap.onCall(0).yields().returns(undefined) 327 | handlers.message = () => consumer.stop() 328 | consumer.start() 329 | 330 | t.true(peers[0].messages.canTake.calledOnce) 331 | t.true(peers[1].messages.canTake.notCalled) 332 | }) 333 | 334 | test('stop() prevents non-peer message from being handled when called while creating the peer', async t => { 335 | const { consumer, handlers, nonPeerReceiver, scheduler } = t.context 336 | scheduler.asap.onCall(0).yields() 337 | nonPeerReceiver.messages.canTake.returns(true) 338 | nonPeerReceiver.messages.take.returns([]) 339 | let create 340 | nonPeerReceiver.createPeer.returns(new Promise(resolve => { 341 | create = resolve 342 | })) 343 | 344 | consumer.start() 345 | t.true(nonPeerReceiver.createPeer.calledOnce) 346 | consumer.stop() 347 | 348 | create() 349 | await Promise.resolve() 350 | 351 | t.true(handlers.message.notCalled) 352 | }) 353 | -------------------------------------------------------------------------------- /test/lib/Log.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { stub } from 'sinon' 3 | 4 | import Entry from 'dist/lib/Entry' 5 | import Log from 'dist/lib/Log' 6 | 7 | import fork from './helpers/fork-context' 8 | import macro from './helpers/macro' 9 | 10 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 11 | const { Promise } = global 12 | 13 | function makeEntries (index, term, count) { 14 | const entries = [] 15 | for (; entries.length < count; index++) { 16 | entries.push(new Entry(index, term, Symbol())) 17 | } 18 | return entries 19 | } 20 | 21 | function setupResolvePersistEntries (context) { 22 | const { persistEntries } = context 23 | let resolvePersistEntries 24 | persistEntries.returns(new Promise(resolve => { 25 | resolvePersistEntries = resolve 26 | })) 27 | return Object.assign({ resolvePersistEntries }, context) 28 | } 29 | 30 | test.beforeEach(t => { 31 | const applier = stub({ 32 | _lastQueued () {}, 33 | get lastQueued () { return this._lastQueued() }, 34 | finish () {}, 35 | destroy () {}, 36 | reset () {}, 37 | enqueue () {} 38 | }) 39 | applier._lastQueued.returns(0) 40 | 41 | const persistEntries = stub().returns(Promise.resolve()) 42 | 43 | const log = new Log({ applier, persistEntries }) 44 | 45 | Object.assign(t.context, { 46 | applier, 47 | log, 48 | persistEntries 49 | }) 50 | }) 51 | 52 | const preseeded = fork().beforeEach(async t => { 53 | const { log, persistEntries } = t.context 54 | const entries = [1, 2, 3].map(index => new Entry(index, 1, Symbol(`seed entry ${index}`))) 55 | await log.mergeEntries(entries) 56 | persistEntries.reset() 57 | Object.assign(t.context, { entries }) 58 | }) 59 | 60 | const persistingAppend = fork().beforeEach(t => { 61 | const { log, persistEntries, resolvePersistEntries } = setupResolvePersistEntries(t.context) 62 | const promise = log.appendValue(1, Symbol) 63 | const finish = () => { 64 | resolvePersistEntries() 65 | return promise 66 | } 67 | const { args: [[entry]] } = persistEntries.firstCall 68 | Object.assign(t.context, { entry, finish }) 69 | }) 70 | 71 | const replaceResetsApplier = macro((t, lastApplied) => { 72 | const { applier, log } = t.context 73 | log.replace([], ...(lastApplied ? [lastApplied] : [])) 74 | 75 | t.true(applier.reset.calledOnce) 76 | const { args: [to] } = applier.reset.firstCall 77 | const expected = lastApplied || 0 78 | t.true(to === expected) 79 | }, suffix => `replace() invokes reset() on the applier, ${suffix}`) 80 | 81 | test('lastIndex is initialized to 0', t => { 82 | const { log } = t.context 83 | t.true(log.lastIndex === 0) 84 | }) 85 | 86 | test('lastTerm is initialized to 0', t => { 87 | const { log } = t.context 88 | t.true(log.lastTerm === 0) 89 | }) 90 | 91 | test('close() invokes finish() on the applier and returns the result', t => { 92 | const { applier, log } = t.context 93 | const result = Symbol() 94 | applier.finish.returns(result) 95 | t.true(log.close() === result) 96 | }) 97 | 98 | test('destroy() invokes destroy() on the applier', t => { 99 | const { applier, log } = t.context 100 | log.destroy() 101 | t.true(applier.destroy.calledOnce) 102 | }) 103 | 104 | test('with the lastApplied value (if provided)', replaceResetsApplier) 105 | test('with the default lastApplied value of 0 (if necessary)', replaceResetsApplier) 106 | 107 | preseeded.test('replace() clears previous entries', t => { 108 | const { entries, log } = t.context 109 | for (const entry of entries) { 110 | t.true(log.getEntry(entry.index) === entry) 111 | } 112 | 113 | log.replace(makeEntries(1, 1, 3)) 114 | for (const entry of entries) { 115 | t.true(log.getEntry(entry.index) !== entry) 116 | } 117 | }) 118 | 119 | preseeded.test('without new entries, replace() resets lastIndex to 0', t => { 120 | const { log } = t.context 121 | t.true(log.lastIndex > 0) 122 | log.replace([]) 123 | t.true(log.lastIndex === 0) 124 | }) 125 | 126 | preseeded.test('without new entries, replace() resets lastTerm to 0', t => { 127 | const { log } = t.context 128 | t.true(log.lastTerm > 0) 129 | log.replace([]) 130 | t.true(log.lastTerm === 0) 131 | }) 132 | 133 | test('replace() adds each new entry', t => { 134 | const { log } = t.context 135 | const entries = makeEntries(1, 1, 3) 136 | log.replace(entries) 137 | for (const entry of entries) { 138 | t.true(log.getEntry(entry.index) === entry) 139 | } 140 | }) 141 | 142 | test('replace() sets lastIndex to the index of the last entry', t => { 143 | const { log } = t.context 144 | const entries = makeEntries(1, 1, 3) 145 | log.replace(entries) 146 | t.true(log.lastIndex === 3) 147 | }) 148 | 149 | test('replace() sets lastTerm to the term of the last entry', t => { 150 | const { log } = t.context 151 | const entries = makeEntries(1, 1, 2).concat(new Entry(3, 3, Symbol())) 152 | log.replace(entries) 153 | t.true(log.lastTerm === 3) 154 | }) 155 | 156 | test('replace() skips conflicting entries', t => { 157 | const { log } = t.context 158 | const entries = makeEntries(1, 1, 3).concat(makeEntries(2, 1, 3)) 159 | log.replace(entries) 160 | 161 | t.true(log.getEntry(1) === entries[0]) 162 | for (const entry of entries.slice(1, 3)) { 163 | t.true(log.getEntry(entry.index) !== entry) 164 | } 165 | for (const entry of entries.slice(3)) { 166 | t.true(log.getEntry(entry.index) === entry) 167 | } 168 | }) 169 | 170 | preseeded.test('deleteConflictingEntries() deletes all entries in order, starting at fromIndex', t => { 171 | const { entries, log } = t.context 172 | log.deleteConflictingEntries(2) 173 | t.true(log.getEntry(1) === entries[0]) 174 | t.true(log.getEntry(2) === undefined) 175 | t.true(log.getEntry(3) === undefined) 176 | }) 177 | 178 | preseeded.test('getTerm() returns 0 when the given index is 0', t => { 179 | const { log } = t.context 180 | t.true(log.getTerm(0) === 0) 181 | }) 182 | 183 | preseeded.test('getTerm() returns the term for the entry at the given index', t => { 184 | const { log } = t.context 185 | t.true(log.getTerm(1) === 1) 186 | }) 187 | 188 | preseeded.test('getTerm() throws a TypeError if no entry exists at the given index', t => { 189 | const { log } = t.context 190 | t.throws(() => log.getTerm(4), TypeError) 191 | }) 192 | 193 | test('getEntry() returns the entry at the given index', t => { 194 | const { log } = t.context 195 | const entry = new Entry(1, 1, Symbol()) 196 | log.replace([entry]) 197 | t.true(log.getEntry(1) === entry) 198 | }) 199 | 200 | test('getEntry() returns undefined if no entry exists at the given index', t => { 201 | const { log } = t.context 202 | t.true(log.getEntry(1) === undefined) 203 | }) 204 | 205 | preseeded.test('getEntriesSince() returns all entries in order, starting at the given index', t => { 206 | const { entries, log } = t.context 207 | const since = log.getEntriesSince(2) 208 | t.true(since.length === 2) 209 | t.true(since[0] === entries[1]) 210 | t.true(since[1] === entries[2]) 211 | }) 212 | 213 | test('appendValue() persists an entry for the value', t => { 214 | const { log, persistEntries } = t.context 215 | const value = Symbol() 216 | log.appendValue(1, value) 217 | 218 | t.true(persistEntries.calledOnce) 219 | const { args: [[entry]] } = persistEntries.firstCall 220 | t.true(entry.value === value) 221 | }) 222 | 223 | preseeded.test('appendValue() increments the index for the new entry compared to the last entry', t => { 224 | const { entries, log, persistEntries } = t.context 225 | const [, , last] = entries 226 | log.appendValue(1, Symbol()) 227 | const { args: [[entry]] } = persistEntries.firstCall 228 | t.true(entry.index === last.index + 1) 229 | }) 230 | 231 | test('appendValue() persists an entry with the given term', t => { 232 | const { log, persistEntries } = t.context 233 | log.appendValue(2, Symbol()) 234 | const { args: [[entry]] } = persistEntries.firstCall 235 | t.true(entry.term === 2) 236 | }) 237 | 238 | persistingAppend.test('appendValue() sets lastIndex once the entry is persisted', async t => { 239 | const { finish, log } = t.context 240 | t.true(log.lastIndex === 0) 241 | await finish() 242 | t.true(log.lastIndex === 1) 243 | }) 244 | 245 | persistingAppend.test('appendValue() sets lastTerm once the entry is persisted', async t => { 246 | const { finish, log } = t.context 247 | t.true(log.lastTerm === 0) 248 | await finish() 249 | t.true(log.lastTerm === 1) 250 | }) 251 | 252 | persistingAppend.test('appendValue() adds the new entry once it is persisted', async t => { 253 | const { entry, finish, log } = t.context 254 | t.true(log.getEntry(1) === undefined) 255 | await finish() 256 | t.true(log.getEntry(1) === entry) 257 | }) 258 | 259 | persistingAppend.test('appendValue() returns a promise that is fulfilled with the new entry, once it is persisted', async t => { 260 | const { entry, finish } = t.context 261 | t.true(await finish() === entry) 262 | }) 263 | 264 | test('mergeEntries() persists the entries', t => { 265 | const { log, persistEntries } = t.context 266 | const entries = makeEntries(1, 1, 3) 267 | log.mergeEntries(entries) 268 | 269 | const { args: [persisting] } = persistEntries.firstCall 270 | t.true(persisting.length === 3) 271 | t.true(persisting[0] === entries[0]) 272 | t.true(persisting[1] === entries[1]) 273 | t.true(persisting[2] === entries[2]) 274 | }) 275 | 276 | preseeded.test('mergeEntries() does not persist duplicate entries', t => { 277 | const { entries, log, persistEntries } = t.context 278 | const extra = new Entry(4, 1, Symbol()) 279 | log.mergeEntries(entries.slice(1).concat(extra)) 280 | 281 | const { args: [persisting] } = persistEntries.firstCall 282 | t.true(persisting.length === 1) 283 | t.true(persisting[0] === extra) 284 | }) 285 | 286 | preseeded.test('mergeEntries() does not persist if there are no new entries to be merged', async t => { 287 | const { entries, log, persistEntries } = t.context 288 | const promise = log.mergeEntries(entries) 289 | t.true(persistEntries.notCalled) 290 | t.truthy(promise) 291 | t.true(await promise === undefined) 292 | }) 293 | 294 | test('mergeEntries() adds each entry once it is persisted', async t => { 295 | const { log, resolvePersistEntries } = setupResolvePersistEntries(t.context) 296 | const entries = makeEntries(1, 1, 3) 297 | const promise = log.mergeEntries(entries) 298 | t.true(log.getEntry(1) === undefined) 299 | 300 | resolvePersistEntries() 301 | await promise 302 | for (const entry of entries) { 303 | t.true(log.getEntry(entry.index) === entry) 304 | } 305 | }) 306 | 307 | preseeded.test('mergeEntries() deletes earlier conflicting entries', async t => { 308 | const { entries, log, resolvePersistEntries } = setupResolvePersistEntries(t.context) 309 | const newEntries = makeEntries(2, 2, 3) 310 | const promise = log.mergeEntries(newEntries) 311 | t.true(log.getEntry(2) === entries[1]) 312 | 313 | resolvePersistEntries() 314 | await promise 315 | 316 | t.true(log.getEntry(1) === entries[0]) 317 | for (const entry of newEntries) { 318 | t.true(log.getEntry(entry.index) === entry) 319 | } 320 | }) 321 | 322 | preseeded.test('mergeEntries() deletes conflicting entries even if no new entries are merged', async t => { 323 | const { entries, log } = setupResolvePersistEntries(t.context) 324 | await log.mergeEntries([], 1, 1) 325 | 326 | t.true(log.getEntry(1) === entries[0]) 327 | t.true(!log.getEntry(2)) 328 | t.true(log.lastIndex === 1) 329 | t.true(log.lastTerm === 1) 330 | }) 331 | 332 | test('mergeEntries() sets lastIndex once all entries are persisted', async t => { 333 | const { log, resolvePersistEntries } = setupResolvePersistEntries(t.context) 334 | const entries = makeEntries(1, 1, 3) 335 | const [, , last] = entries 336 | const promise = log.mergeEntries(entries) 337 | t.true(log.lastIndex === 0) 338 | 339 | resolvePersistEntries() 340 | await promise 341 | 342 | t.true(log.lastIndex === last.index) 343 | }) 344 | 345 | test('mergeEntries() sets lastTerm once all entries are persisted', async t => { 346 | const { log, resolvePersistEntries } = setupResolvePersistEntries(t.context) 347 | const entries = makeEntries(1, 1, 2).concat(new Entry(3, 2, Symbol())) 348 | const [, , last] = entries 349 | const promise = log.mergeEntries(entries) 350 | t.true(log.lastTerm === 0) 351 | 352 | resolvePersistEntries() 353 | await promise 354 | 355 | t.true(log.lastTerm === last.term) 356 | }) 357 | 358 | test('commit() invokes enqueue() on the applier, with the entry at the given index', async t => { 359 | const { applier, log } = t.context 360 | const entry = await log.appendValue(1, Symbol()) 361 | log.commit(entry.index) 362 | 363 | t.true(applier.enqueue.calledOnce) 364 | const { args: [enqueued, resolve] } = applier.enqueue.firstCall 365 | t.true(enqueued === entry) 366 | t.true(typeof resolve === 'function') 367 | }) 368 | 369 | preseeded.test('commit() enqueues earlier, uncommitted entries up to and including the one at the given index, in order', async t => { 370 | const { applier, entries, log } = t.context 371 | const latest = await log.appendValue(1, Symbol()) 372 | applier._lastQueued.returns(1) 373 | log.commit(latest.index) 374 | 375 | t.true(applier.enqueue.calledThrice) 376 | for (let n = 0; n < 3; n++) { 377 | const { args: [entry, resolve] } = applier.enqueue.getCall(n) 378 | if (n === 0) { 379 | t.true(entry === entries[1]) 380 | } else if (n === 1) { 381 | t.true(entry === entries[2]) 382 | } else { 383 | t.true(entry === latest) 384 | } 385 | 386 | if (n < 2) { 387 | t.true(resolve === undefined) 388 | } else { 389 | t.true(typeof resolve === 'function') 390 | } 391 | } 392 | }) 393 | 394 | test('commit() returns a promise that is fulfilled with the result of committing the entry at the given index', async t => { 395 | const { applier, log } = t.context 396 | const entry = await log.appendValue(1, Symbol()) 397 | const p = log.commit(entry.index) 398 | 399 | const { args: [, resolve] } = applier.enqueue.firstCall 400 | const result = Symbol() 401 | resolve(result) 402 | t.true(await p === result) 403 | }) 404 | 405 | test('checkOutdated() return true if the term is less than the last term', t => { 406 | const { log } = t.context 407 | log.replace([new Entry(2, 2)]) 408 | t.true(log.checkOutdated(1, 2)) 409 | }) 410 | 411 | test('checkOutdated() return true if the term is equal to the last term, but the index is less than the last index', t => { 412 | const { log } = t.context 413 | log.replace([new Entry(2, 2)]) 414 | t.true(log.checkOutdated(2, 1)) 415 | }) 416 | 417 | test('checkOutdated() return false if the term is greater than the last term', t => { 418 | const { log } = t.context 419 | log.replace([new Entry(2, 2)]) 420 | t.false(log.checkOutdated(3, 2)) 421 | }) 422 | 423 | test('checkOutdated() return false if the term is equal to the last term, and the index is equal to the last index', t => { 424 | const { log } = t.context 425 | log.replace([new Entry(2, 2)]) 426 | t.false(log.checkOutdated(2, 2)) 427 | }) 428 | 429 | test('checkOutdated() return false if the term is equal to the last term, and the index is greater than the last index', t => { 430 | const { log } = t.context 431 | log.replace([new Entry(2, 2)]) 432 | t.false(log.checkOutdated(2, 3)) 433 | }) 434 | -------------------------------------------------------------------------------- /test/lib/LogEntryApplier.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { spy, stub } from 'sinon' 3 | 4 | import { Noop } from 'dist/lib/symbols' 5 | import Entry from 'dist/lib/Entry' 6 | import LogEntryApplier from 'dist/lib/LogEntryApplier' 7 | 8 | import macro from './helpers/macro' 9 | 10 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 11 | const { Promise } = global 12 | 13 | function setupDoApply (context) { 14 | const { applyEntry } = context 15 | let doApply 16 | applyEntry.returns(new Promise(resolve => { 17 | doApply = result => { 18 | resolve(result) 19 | return Promise.resolve() 20 | } 21 | })) 22 | return Object.assign({ doApply }, context) 23 | } 24 | 25 | test.beforeEach(t => { 26 | const applyEntry = stub().returns(Promise.resolve()) 27 | const crashHandler = stub() 28 | const applier = new LogEntryApplier({ applyEntry, crashHandler }) 29 | 30 | Object.assign(t.context, { 31 | applier, 32 | applyEntry, 33 | crashHandler 34 | }) 35 | }) 36 | 37 | const resetThrowsTypeError = macro((t, lastApplied) => { 38 | const { applier } = t.context 39 | t.throws( 40 | () => applier.reset(lastApplied), 41 | TypeError, 42 | 'Cannot reset log entry applier: last-applied index must be a safe, non-negative integer') 43 | }, suffix => `reset() throws if the given lastApplied value is ${suffix}`) 44 | 45 | test('lastApplied is initialized to 0', t => { 46 | const { applier } = t.context 47 | t.true(applier.lastApplied === 0) 48 | }) 49 | 50 | test('lastQueued is initialized to 0', t => { 51 | const { applier } = t.context 52 | t.true(applier.lastQueued === 0) 53 | }) 54 | 55 | test('not an integer', resetThrowsTypeError, '🙊') 56 | test('not a safe integer', resetThrowsTypeError, Number.MAX_SAFE_INTEGER + 1) 57 | test('lower than 0', resetThrowsTypeError, -1) 58 | 59 | test('reset() throws if called while entries are being appended', t => { 60 | const { applier } = t.context 61 | applier.enqueue(new Entry(1, 1, Symbol())) 62 | t.throws( 63 | () => applier.reset(0), 64 | Error, 65 | 'Cannot reset log entry applier while entries are being applied') 66 | }) 67 | 68 | test('reset() sets lastApplied to the lastApplied value', t => { 69 | const { applier } = t.context 70 | applier.reset(10) 71 | t.true(applier.lastApplied === 10) 72 | }) 73 | 74 | test('reset() sets lastQueued to the lastApplied value', t => { 75 | const { applier } = t.context 76 | applier.reset(10) 77 | t.true(applier.lastQueued === 10) 78 | }) 79 | 80 | test('enqueue() immediately sets lastQueued to the entry’s index', t => { 81 | const { applier } = t.context 82 | applier.enqueue(new Entry(1, 1, Symbol())) 83 | t.true(applier.lastQueued === 1) 84 | }) 85 | 86 | test('enqueue() applies the entry', t => { 87 | const { applier, applyEntry } = t.context 88 | const entry = new Entry(1, 1, Symbol()) 89 | applier.enqueue(entry) 90 | t.true(applyEntry.calledOnce) 91 | const { args: [applied] } = applyEntry.firstCall 92 | t.true(applied === entry) 93 | }) 94 | 95 | test('enqueue() sets lastApplied to the entry’s index, once it’s been applied', async t => { 96 | const { applier, doApply } = setupDoApply(t.context) 97 | 98 | applier.enqueue(new Entry(3, 1, Symbol())) 99 | t.true(applier.lastApplied === 0) 100 | 101 | await doApply() 102 | t.true(applier.lastApplied === 3) 103 | }) 104 | 105 | test('enqueue() prevents two entries being applied at the same time', t => { 106 | const { applier, applyEntry } = t.context 107 | applier.enqueue(new Entry(1, 1, Symbol())) 108 | applier.enqueue(new Entry(2, 1, Symbol())) 109 | t.true(applyEntry.calledOnce) 110 | }) 111 | 112 | test('enqueue() applies one entry after the other', async t => { 113 | const { applier, applyEntry } = t.context 114 | const first = new Entry(1, 1, Symbol()) 115 | const second = new Entry(2, 1, Symbol()) 116 | 117 | const firstApplied = spy() 118 | applier.enqueue(first, firstApplied) 119 | 120 | await new Promise(resolve => { 121 | applier.enqueue(second, () => { 122 | t.true(firstApplied.calledOnce) 123 | resolve() 124 | }) 125 | }) 126 | 127 | t.true(applyEntry.calledTwice) 128 | const { args: [[actualFirst], [actualSecond]] } = applyEntry 129 | t.true(actualFirst === first) 130 | t.true(actualSecond === second) 131 | }) 132 | 133 | test('enqueue() calls the resolve callback with the result of applying the entry', async t => { 134 | const { applier, doApply } = setupDoApply(t.context) 135 | const wasApplied = spy() 136 | applier.enqueue(new Entry(1, 1, Symbol()), wasApplied) 137 | 138 | const result = Symbol() 139 | await doApply(result) 140 | 141 | t.true(wasApplied.calledOnce) 142 | const { args: [applicationResult] } = wasApplied.firstCall 143 | t.true(applicationResult === result) 144 | }) 145 | 146 | test('enqueue() does not apply Noop entries', async t => { 147 | const { applier, applyEntry } = t.context 148 | await new Promise(resolve => { 149 | applier.enqueue(new Entry(1, 1, Noop), resolve) 150 | }) 151 | 152 | t.true(applyEntry.notCalled) 153 | }) 154 | 155 | test('enqueue() sets lastApplied to the entry’s index, even if it is a Noop entry', async t => { 156 | const { applier } = t.context 157 | await new Promise(resolve => { 158 | applier.enqueue(new Entry(3, 1, Noop), resolve) 159 | }) 160 | 161 | t.true(applier.lastApplied === 3) 162 | }) 163 | 164 | test('enqueue() calls the resolve callback with an undefined result when enqueuing a Noop entry', async t => { 165 | const { applier } = t.context 166 | const result = new Promise(resolve => { 167 | applier.enqueue(new Entry(3, 1, Noop), resolve) 168 | }) 169 | 170 | t.true(await result === undefined) 171 | }) 172 | 173 | test('the crashHandler is called if an entry cannot be applied', async t => { 174 | const { applier, crashHandler, doApply } = setupDoApply(t.context) 175 | applier.enqueue(new Entry(1, 1, Symbol())) 176 | 177 | const err = Symbol() 178 | doApply(Promise.reject(err)) 179 | await new Promise(resolve => setImmediate(resolve)) 180 | 181 | t.true(crashHandler.calledOnce) 182 | const { args: [reason] } = crashHandler.firstCall 183 | t.true(reason === err) 184 | }) 185 | 186 | test('finish() returns a fulfilled promise if no entries are being applied', async t => { 187 | const { applier } = t.context 188 | t.true(await applier.finish() === undefined) 189 | }) 190 | 191 | test('finish() returns a promise that is fulfilled when the last entry has been applied', async t => { 192 | const { applier, doApply } = setupDoApply(t.context) 193 | const wasApplied = spy() 194 | applier.enqueue(new Entry(1, 1, Symbol()), wasApplied) 195 | 196 | const finished = spy() 197 | const promise = applier.finish().then(finished) 198 | 199 | t.true(wasApplied.notCalled) 200 | doApply() 201 | await promise 202 | 203 | t.true(wasApplied.calledBefore(finished)) 204 | const { args: [value] } = finished.firstCall 205 | t.true(value === undefined) 206 | }) 207 | 208 | test('destroy() stops any remaining entries from being applied', async t => { 209 | const { applier, applyEntry } = t.context 210 | const firstApplied = new Promise(resolve => { 211 | applier.enqueue(new Entry(1, 1, Symbol()), resolve) 212 | }) 213 | applier.enqueue(new Entry(2, 1, Symbol())) 214 | 215 | t.true(applyEntry.calledOnce) 216 | applier.destroy() 217 | 218 | await firstApplied 219 | t.true(applyEntry.calledOnce) 220 | }) 221 | -------------------------------------------------------------------------------- /test/lib/MessageBuffer.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { stub } from 'sinon' 3 | 4 | import MessageBuffer from 'dist/lib/MessageBuffer' 5 | 6 | import fork from './helpers/fork-context' 7 | 8 | test.beforeEach(t => { 9 | const stream = stub({ 10 | read () {}, 11 | once () {} 12 | }) 13 | stream.read.returns(null) 14 | const buffer = new MessageBuffer(stream) 15 | 16 | Object.assign(t.context, { 17 | buffer, 18 | stream 19 | }) 20 | }) 21 | 22 | const streamHasMessage = fork().beforeEach(t => { 23 | const { stream } = t.context 24 | const message = Symbol() 25 | stream.read.onCall(0).returns(message) 26 | Object.assign(t.context, { message }) 27 | }) 28 | 29 | const hasBufferedMessage = streamHasMessage.fork().beforeEach(t => { 30 | const { buffer, stream } = t.context 31 | buffer.canTake() 32 | t.true(stream.read.calledOnce) 33 | }) 34 | 35 | test('take() reads from the stream', t => { 36 | const { buffer, stream } = t.context 37 | const message = Symbol() 38 | stream.read.onCall(0).returns(message) 39 | 40 | t.true(buffer.take() === message) 41 | t.true(stream.read.calledOnce) 42 | }) 43 | 44 | hasBufferedMessage.test('take() returns a buffered message', t => { 45 | const { buffer, message } = t.context 46 | t.true(buffer.take() === message) 47 | }) 48 | 49 | hasBufferedMessage.test('take() reads from the stream after the buffered message is taken', t => { 50 | const { buffer, message, stream } = t.context 51 | const another = Symbol() 52 | stream.read.onCall(1).returns(another) 53 | 54 | t.true(buffer.take() === message) 55 | t.true(buffer.take() === another) 56 | t.true(stream.read.calledTwice) 57 | }) 58 | 59 | test('canTake() reads from the stream if there is no buffered message', t => { 60 | const { buffer, stream } = t.context 61 | stream.read.onCall(0).returns(Symbol()) 62 | buffer.canTake() 63 | t.true(stream.read.calledOnce) 64 | }) 65 | 66 | test('canTake() returns true if a message was read', t => { 67 | const { buffer, stream } = t.context 68 | stream.read.onCall(0).returns(Symbol()) 69 | t.true(buffer.canTake()) 70 | }) 71 | 72 | test('canTake() returns false if a message was read', t => { 73 | const { buffer } = t.context 74 | t.false(buffer.canTake()) 75 | }) 76 | 77 | hasBufferedMessage.test('canTake() returns true if there is a buffered message', t => { 78 | const { buffer } = t.context 79 | t.true(buffer.canTake()) 80 | }) 81 | 82 | test('await() reads from the stream if there is no buffered message', t => { 83 | const { buffer, stream } = t.context 84 | stream.read.onCall(0).returns(Symbol()) 85 | buffer.await() 86 | t.true(stream.read.calledOnce) 87 | }) 88 | 89 | streamHasMessage.test('await() returns a fulfilled promise if it reads a message from the stream', async t => { 90 | const { buffer } = t.context 91 | t.true(await buffer.await() === undefined) 92 | }) 93 | 94 | streamHasMessage.test('await() returns the same promise if called multiple times in the same turn', t => { 95 | const { buffer } = t.context 96 | t.true(buffer.await() === buffer.await()) 97 | }) 98 | 99 | streamHasMessage.test('await() returns different promises if called in different turns', async t => { 100 | const { buffer } = t.context 101 | const promise = buffer.await() 102 | await promise 103 | t.true(promise !== buffer.await()) 104 | }) 105 | 106 | test('await() listens for the readable event if it did not read a message from the stream', t => { 107 | const { buffer, stream } = t.context 108 | buffer.await() 109 | t.true(stream.once.calledOnce) 110 | const { args: [event, listener] } = stream.once.firstCall 111 | t.true(event === 'readable') 112 | t.true(typeof listener === 'function') 113 | }) 114 | 115 | test('await() returns a promise that is fulfilled when the stream emits the readable event', async t => { 116 | const { buffer, stream } = t.context 117 | const promise = buffer.await() 118 | const { args: [, fire] } = stream.once.firstCall 119 | 120 | fire() 121 | t.true(await promise === undefined) 122 | }) 123 | -------------------------------------------------------------------------------- /test/lib/NonPeerReceiver.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import proxyquire from 'proxyquire' 3 | import { stub } from 'sinon' 4 | 5 | import dist from './helpers/dist' 6 | 7 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 8 | const { Promise } = global 9 | 10 | const shared = { 11 | MessageBuffer () {}, 12 | Peer () {} 13 | } 14 | 15 | const { default: NonPeerReceiver } = proxyquire.noCallThru()(dist('lib/NonPeerReceiver'), { 16 | './MessageBuffer': function (...args) { return shared.MessageBuffer(...args) }, 17 | './Peer': function (...args) { return shared.Peer(...args) } 18 | }) 19 | 20 | test.beforeEach(t => { 21 | const connect = stub().returns(new Promise(() => {})) 22 | const MessageBuffer = stub() 23 | const Peer = stub() 24 | const stream = stub({ read () {} }) 25 | 26 | // Note that the next tests' beforeEach hook overrides the shared stubs. Tests 27 | // where NonPeerReceiver, MessageBuffer or Peer are instantiated 28 | // asynchronously need to be marked as serial. 29 | Object.assign(shared, { MessageBuffer, Peer }) 30 | 31 | Object.assign(t.context, { 32 | connect, 33 | MessageBuffer, 34 | Peer, 35 | stream 36 | }) 37 | }) 38 | 39 | test('create a message buffer', t => { 40 | const { connect, MessageBuffer, stream } = t.context 41 | const messages = {} 42 | MessageBuffer.returns(messages) 43 | 44 | const receiver = new NonPeerReceiver(stream, connect) 45 | t.true(MessageBuffer.calledOnce) 46 | const { args: [actualStream] } = MessageBuffer.firstCall 47 | t.true(actualStream === stream) 48 | t.true(receiver.messages === messages) 49 | }) 50 | 51 | test('createPeer() connects to the address', t => { 52 | const { connect, stream } = t.context 53 | const receiver = new NonPeerReceiver(stream, connect) 54 | const peerAddress = Symbol() 55 | receiver.createPeer(peerAddress) 56 | 57 | t.true(connect.calledOnce) 58 | const { args: [{ address, writeOnly }] } = connect.firstCall 59 | t.true(address === peerAddress) 60 | t.true(writeOnly === true) 61 | }) 62 | 63 | test.serial('createPeer() returns a promise that is fulfilled with the peer once it’s connected', async t => { 64 | const { connect, Peer } = t.context 65 | const peerAddress = Symbol() 66 | const stream = {} 67 | connect.returns(Promise.resolve(stream)) 68 | 69 | const peer = {} 70 | Peer.returns(peer) 71 | 72 | const receiver = new NonPeerReceiver(stream, connect) 73 | t.true(await receiver.createPeer(peerAddress) === peer) 74 | const { args: [addressForPeer, streamForPeer] } = Peer.lastCall 75 | t.true(addressForPeer === peerAddress) 76 | t.true(streamForPeer === stream) 77 | }) 78 | 79 | test('createPeer() returns a promise that is rejected with the connection failure, if any', async t => { 80 | const { connect, stream } = t.context 81 | const err = Symbol() 82 | connect.returns(Promise.reject(err)) 83 | 84 | const receiver = new NonPeerReceiver(stream, connect) 85 | const reason = await t.throws(receiver.createPeer()) 86 | t.true(reason === err) 87 | }) 88 | -------------------------------------------------------------------------------- /test/lib/Peer.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import proxyquire from 'proxyquire' 3 | import { stub } from 'sinon' 4 | 5 | import dist from './helpers/dist' 6 | 7 | const shared = { 8 | MessageBuffer () {} 9 | } 10 | 11 | const { default: Peer } = proxyquire.noCallThru()(dist('lib/Peer'), { 12 | './MessageBuffer': function (...args) { return shared.MessageBuffer(...args) } 13 | }) 14 | 15 | test.beforeEach(t => { 16 | const MessageBuffer = stub() 17 | const stream = stub({ read () {}, write () {} }) 18 | 19 | // Note that the next tests' beforeEach hook overrides the shared stubs. Tests 20 | // where Peer or MessageBuffer are instantiated asynchronously need to be 21 | // marked as serial. 22 | Object.assign(shared, { MessageBuffer }) 23 | 24 | Object.assign(t.context, { MessageBuffer, stream }) 25 | }) 26 | 27 | test('set address on the instance', t => { 28 | const { stream } = t.context 29 | const address = Symbol() 30 | t.true(new Peer(address, stream).address === address) 31 | }) 32 | 33 | test('set the address’ serverId on the instance, as id', t => { 34 | const { stream } = t.context 35 | const serverId = Symbol() 36 | t.true(new Peer({ serverId }, stream).id === serverId) 37 | }) 38 | 39 | test('create a MessageBuffer instance for the stream', t => { 40 | const { MessageBuffer, stream: expectedStream } = t.context 41 | const messages = {} 42 | MessageBuffer.returns(messages) 43 | 44 | const peer = new Peer({}, expectedStream) 45 | t.true(MessageBuffer.calledOnce) 46 | const { args: [stream] } = MessageBuffer.firstCall 47 | t.true(stream === expectedStream) 48 | t.true(peer.messages === messages) 49 | }) 50 | 51 | test('send() writes the message to the stream', t => { 52 | const { stream } = t.context 53 | const message = Symbol() 54 | const peer = new Peer({}, stream) 55 | peer.send(message) 56 | 57 | t.true(stream.write.calledOnce) 58 | const { args: [written] } = stream.write.firstCall 59 | t.true(written === message) 60 | }) 61 | -------------------------------------------------------------------------------- /test/lib/Scheduler.js: -------------------------------------------------------------------------------- 1 | // https://github.com/avajs/eslint-plugin-ava/issues/127 2 | /* eslint-disable ava/use-t */ 3 | 4 | import test from 'ava' 5 | import { spy, stub } from 'sinon' 6 | 7 | import Scheduler from 'dist/lib/Scheduler' 8 | 9 | import macro from './helpers/macro' 10 | 11 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 12 | const { Promise } = global 13 | 14 | const asapReturnsPending = macro(async (t, fn, setup = () => {}) => { 15 | const { scheduler } = t.context 16 | setup(t.context) 17 | 18 | const expected = Symbol() 19 | const soon = new Promise(resolve => setImmediate(() => resolve(expected))) 20 | t.true(await Promise.race([scheduler.asap(null, fn), soon]) === expected) 21 | }, suffix => `asap() returns a perpetually pending promise ${suffix}`) 22 | 23 | const asapInvokesCrashHandler = macro(async (t, crashingFn) => { 24 | const { crashHandler, scheduler } = t.context 25 | const err = Symbol() 26 | scheduler.asap(null, () => crashingFn(err)) 27 | 28 | await new Promise(resolve => setImmediate(resolve)) 29 | t.true(crashHandler.calledOnce) 30 | const { args: [reason] } = crashHandler.firstCall 31 | t.true(reason === err) 32 | }, suffix => `asap() invokes the crashHandler ${suffix}`) 33 | 34 | test.beforeEach(t => { 35 | const crashHandler = stub() 36 | const scheduler = new Scheduler(crashHandler) 37 | 38 | Object.assign(t.context, { crashHandler, scheduler }) 39 | }) 40 | 41 | test('asap() calls handleAbort when invoked after the scheduler is aborted', t => { 42 | const { scheduler } = t.context 43 | scheduler.abort() 44 | const handleAbort = spy() 45 | scheduler.asap(handleAbort) 46 | t.true(handleAbort.calledOnce) 47 | }) 48 | 49 | test('when invoked after the scheduler is aborted', asapReturnsPending, () => {}, ({ scheduler }) => scheduler.abort()) 50 | 51 | test('asap() synchronously calls fn if no other operation is currently active', t => { 52 | const { scheduler } = t.context 53 | const fn = spy() 54 | scheduler.asap(null, fn) 55 | 56 | t.true(fn.calledOnce) 57 | }) 58 | 59 | test('with the error if fn throws', asapInvokesCrashHandler, err => { throw err }) 60 | 61 | test('if fn throws', asapReturnsPending, () => { throw new Error() }) 62 | 63 | test('asap() returns undefined if fn doesn’t return a promise', t => { 64 | const { scheduler } = t.context 65 | t.true(scheduler.asap(null, () => {}) === undefined) 66 | }) 67 | 68 | test('asap() returns a promise if fn returns a promise', t => { 69 | const { scheduler } = t.context 70 | t.true(scheduler.asap(null, () => new Promise(() => {})) instanceof Promise) 71 | }) 72 | 73 | test('asap() fulfils its promise with undefined once the fn-returned promise fulfils', async t => { 74 | const { scheduler } = t.context 75 | t.true(await scheduler.asap(null, () => Promise.resolve(Symbol())) === undefined) 76 | }) 77 | 78 | test('with the rejection reason if the fn-returned promise rejects', asapInvokesCrashHandler, err => Promise.reject(err)) 79 | test('if the fn-returned promise rejects', asapReturnsPending, () => Promise.reject(new Error())) 80 | 81 | test('asap() prevents two operations from being run at the same time', t => { 82 | const { scheduler } = t.context 83 | scheduler.asap(null, () => new Promise(() => {})) 84 | const second = spy() 85 | scheduler.asap(null, second) 86 | 87 | t.true(second.notCalled) 88 | }) 89 | 90 | test('asap() returns a promise for when the second operation has finished', t => { 91 | const { scheduler } = t.context 92 | scheduler.asap(null, () => new Promise(() => {})) 93 | const p = scheduler.asap(null, () => {}) 94 | t.true(p instanceof Promise) 95 | }) 96 | 97 | test('asap() runs scheduled operations in order', async t => { 98 | const { scheduler } = t.context 99 | scheduler.asap(null, () => Promise.resolve()) 100 | const second = spy() 101 | scheduler.asap(null, second) 102 | const third = spy() 103 | await scheduler.asap(null, third) 104 | 105 | t.true(second.calledBefore(third)) 106 | }) 107 | 108 | test('abort() stops any remaining operations from being run', async t => { 109 | const { scheduler } = t.context 110 | const first = scheduler.asap(null, () => Promise.resolve()) 111 | const second = spy() 112 | scheduler.asap(null, second) 113 | 114 | scheduler.abort() 115 | await first 116 | t.true(second.notCalled) 117 | }) 118 | 119 | test('abort() invokes the handleAbort callbacks of any remaining operations', t => { 120 | const { scheduler } = t.context 121 | const first = spy() 122 | scheduler.asap(first, () => Promise.resolve()) 123 | const second = spy() 124 | scheduler.asap(second, () => {}) 125 | const third = spy() 126 | scheduler.asap(third, () => {}) 127 | 128 | scheduler.abort() 129 | t.true(first.notCalled) 130 | t.true(second.calledBefore(third)) 131 | }) 132 | -------------------------------------------------------------------------------- /test/lib/State.js: -------------------------------------------------------------------------------- 1 | // https://github.com/avajs/eslint-plugin-ava/issues/127 2 | /* eslint-disable ava/use-t */ 3 | 4 | // Macro detection goes wrong 5 | /* eslint-disable ava/no-identical-title */ 6 | 7 | import test from 'ava' 8 | import { stub } from 'sinon' 9 | 10 | import State from 'dist/lib/State' 11 | 12 | import macro from './helpers/macro' 13 | 14 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 15 | const { Promise } = global 16 | 17 | const throwsTypeError = macro((t, method, arg, message) => { 18 | const { state } = t.context 19 | t.throws(() => state[method](arg), TypeError, message) 20 | }, (suffix, method) => `${method}() throws a TypeError if ${method === 'replace' ? 'currentTerm' : 'term'} is ${suffix}`) 21 | 22 | const persistCurrentTermAndVoteFor = macro((t, currentTerm, votedFor, fn) => { 23 | const { persist, state } = t.context 24 | fn(state, votedFor) 25 | const { args: [{ currentTerm: persistedTerm, votedFor: persistedVote }] } = persist.firstCall 26 | t.true(currentTerm === persistedTerm) 27 | t.true(votedFor === persistedVote) 28 | }, prefix => `${prefix} persists the currentTerm and votedFor values`) 29 | 30 | const returnsPromiseForPersistence = macro((t, currentTerm, votedFor, fn) => { 31 | const { state } = t.context 32 | t.true(fn(state, votedFor) instanceof Promise) 33 | }, prefix => `${prefix} returns a promise for when it’s persisted the state`) 34 | 35 | const fulfilsPersistencePromise = macro(async (t, currentTerm, votedFor, fn) => { 36 | const { persist, state } = t.context 37 | persist.returns(Promise.resolve(Symbol())) 38 | t.true(await fn(state, votedFor) === undefined) 39 | }, prefix => `${prefix} fulfils the returned promise once the state has persisted`) 40 | 41 | const rejectsPersistencePromise = macro(async (t, currentTerm, votedFor, fn) => { 42 | const { persist, state } = t.context 43 | const err = new Error() 44 | persist.returns(Promise.reject(err)) 45 | const actualErr = await t.throws(fn(state, votedFor)) 46 | t.true(actualErr === err) 47 | }, prefix => `${prefix} rejects the returned promise if persisting the state fails`) 48 | 49 | test.beforeEach(t => { 50 | const persist = stub().returns(Promise.resolve()) 51 | const state = new State(persist) 52 | 53 | Object.assign(t.context, { persist, state }) 54 | }) 55 | 56 | test('currentTerm is initialized to 0', t => { 57 | const { state } = t.context 58 | t.true(state.currentTerm === 0) 59 | }) 60 | 61 | test('votedFor is initialized to null', t => { 62 | const { state } = t.context 63 | t.true(state.votedFor === null) 64 | }) 65 | 66 | test('replace() sets currentTerm to the currentTerm value', t => { 67 | const { state } = t.context 68 | state.replace({ currentTerm: 1 }) 69 | t.true(state.currentTerm === 1) 70 | }) 71 | 72 | test('replace() sets votedFor to the votedFor value', t => { 73 | const { state } = t.context 74 | const votedFor = Symbol() 75 | state.replace({ currentTerm: 1, votedFor }) 76 | t.true(state.votedFor === votedFor) 77 | }) 78 | 79 | test('nextTerm() throws a RangeError if currentTerm is already the max safe integer', t => { 80 | const { state } = t.context 81 | state.replace({ currentTerm: Number.MAX_SAFE_INTEGER }) 82 | t.throws( 83 | () => state.nextTerm(), 84 | RangeError, 85 | 'Cannot advance term: it is already the maximum safe integer value' 86 | ) 87 | }) 88 | 89 | test('nextTerm() increments currentTerm', t => { 90 | const { state } = t.context 91 | state.nextTerm() 92 | t.true(state.currentTerm === 1) 93 | }) 94 | 95 | test('nextTerm() sets votedFor to the votedFor value', t => { 96 | const { state } = t.context 97 | const votedFor = Symbol() 98 | state.nextTerm(votedFor) 99 | t.true(state.votedFor === votedFor) 100 | }) 101 | 102 | test('setTerm() sets currentTerm to the term value', t => { 103 | const { state } = t.context 104 | state.setTerm(42) 105 | t.true(state.currentTerm === 42) 106 | }) 107 | 108 | test('setTerm() sets votedFor to null', t => { 109 | const { state } = t.context 110 | state.replace({ currentTerm: 1, votedFor: Symbol() }) 111 | state.setTerm(42) 112 | t.true(state.votedFor === null) 113 | }) 114 | 115 | test('setTermAndVote() sets currentTerm to the term value', t => { 116 | const { state } = t.context 117 | state.setTermAndVote(42, Symbol()) 118 | t.true(state.currentTerm === 42) 119 | }) 120 | 121 | test('setTermAndVote() sets votedFor to the votedFor value', t => { 122 | const { state } = t.context 123 | const votedFor = Symbol() 124 | state.setTermAndVote(42, votedFor) 125 | t.true(state.votedFor === votedFor) 126 | }) 127 | 128 | for (const [suffix, value] of [ 129 | ['not an integer', '🙊'], 130 | ['not a safe integer', Number.MAX_SAFE_INTEGER + 1], 131 | ['lower than 0', -1] 132 | ]) { 133 | test(suffix, throwsTypeError, 'replace', { currentTerm: value, votedFor: null }, 'Cannot replace state: current term must be a safe, non-negative integer') 134 | test(suffix, throwsTypeError, 'setTerm', value, 'Cannot set term: current term must be a safe, non-negative integer') 135 | test(suffix, throwsTypeError, 'setTermAndVote', value, 'Cannot set term: current term must be a safe, non-negative integer') 136 | } 137 | 138 | for (const macro of [ 139 | persistCurrentTermAndVoteFor, 140 | returnsPromiseForPersistence, 141 | fulfilsPersistencePromise, 142 | rejectsPersistencePromise 143 | ]) { 144 | test('nextTerm()', macro, 1, Symbol(), (state, votedFor) => state.nextTerm(votedFor)) 145 | test('setTerm()', macro, 42, null, state => state.setTerm(42)) 146 | test('setTermAndVote()', macro, 42, Symbol(), (state, votedFor) => state.setTermAndVote(42, votedFor)) 147 | } 148 | -------------------------------------------------------------------------------- /test/lib/Timers.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import { install as installClock } from 'lolex' 3 | import { spy } from 'sinon' 4 | import Timers from 'dist/lib/Timers' 5 | 6 | const clock = installClock(0, ['clearInterval', 'setInterval', 'clearTimeout', 'setTimeout']) 7 | 8 | function setup () { 9 | return [new Timers(), spy()] 10 | } 11 | 12 | test('clearInterval (intervalObject)', t => { 13 | const [timers, spy] = setup() 14 | const obj = timers.setInterval(spy, 100) 15 | timers.clearInterval(obj) 16 | clock.tick(100) 17 | 18 | t.true(spy.notCalled) 19 | }) 20 | 21 | test('setInterval (callback, delay, ...args)', t => { 22 | const [timers, spy] = setup() 23 | const args = [Symbol(), Symbol()] 24 | timers.setInterval(spy, 100, ...args) 25 | clock.tick(100) 26 | clock.tick(100) 27 | 28 | t.true(spy.calledTwice) 29 | const { args: [firstArgs, secondArgs] } = spy 30 | t.deepEqual(firstArgs, args) 31 | t.deepEqual(secondArgs, args) 32 | }) 33 | 34 | test('clearTimeout (timeoutObject)', t => { 35 | const [timers, spy] = setup() 36 | const obj = timers.setTimeout(spy, 100) 37 | timers.clearTimeout(obj) 38 | clock.tick(100) 39 | 40 | t.true(spy.notCalled) 41 | }) 42 | 43 | test('setTimeout (callback, delay, ...args)', t => { 44 | const [timers, spy] = setup() 45 | const args = [Symbol(), Symbol()] 46 | timers.setTimeout(spy, 100, ...args) 47 | clock.tick(100) 48 | clock.tick(100) 49 | 50 | t.true(spy.calledOnce) 51 | const { args: [firstArgs] } = spy 52 | t.deepEqual(firstArgs, args) 53 | }) 54 | -------------------------------------------------------------------------------- /test/lib/expose-events.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import proxyquire from 'proxyquire' 3 | import { spy, stub } from 'sinon' 4 | 5 | import dist from './helpers/dist' 6 | import macro from './helpers/macro' 7 | 8 | const stubbedProcess = stub({ nextTick () {} }) 9 | const { default: exposeEvents } = proxyquire.noCallThru()(dist('lib/expose-events'), { 10 | './process': stubbedProcess 11 | }) 12 | 13 | const checkDefinition = macro((t, method) => { 14 | const target = {} 15 | exposeEvents(target) 16 | 17 | const { configurable, enumerable, value, writable } = Object.getOwnPropertyDescriptor(target, method) 18 | t.true(configurable) 19 | t.false(enumerable) 20 | t.true(typeof value === 'function') 21 | t.true(writable) 22 | }, (_, method) => `exposeEvents(target) exposes ${method}() on the target object`) 23 | 24 | const propagatesArgs = macro((t, method) => { 25 | const target = {} 26 | const stubbed = stub(exposeEvents(target), method) 27 | 28 | const args = [Symbol(), Symbol()] 29 | target[method](...args) 30 | 31 | t.true(stubbed.calledOnce) 32 | const { args: propagated } = stubbed.firstCall 33 | t.deepEqual(propagated, args) 34 | }, (_, method) => `calling target.${method}() propagates the arguments to the emitter`) 35 | 36 | const returnsThisArg = macro((t, method) => { 37 | const target = {} 38 | stub(exposeEvents(target), method) 39 | 40 | const thisArg = Symbol() 41 | t.true(target[method].call(thisArg) === thisArg) 42 | }, (_, method) => `calling target.${method}() returns the thisArg`) 43 | 44 | const listenerMustBeFunction = macro((t, method) => { 45 | const emitter = exposeEvents({}) 46 | t.throws(() => emitter[method](Symbol()), TypeError, "Parameter 'listener' must be a function, not undefined") 47 | }, (_, method) => `${method}() must be called with a listener function`) 48 | 49 | function setupWithListener (method) { 50 | const target = {} 51 | const emitter = exposeEvents(target) 52 | const event = Symbol() 53 | const listener = spy() 54 | emitter[method](event, listener) 55 | 56 | return { 57 | emit (...args) { 58 | emitter.emit(event, ...args) 59 | }, 60 | emitter, 61 | event, 62 | listener, 63 | target 64 | } 65 | } 66 | 67 | const registersListener = macro((t, method) => { 68 | const { emit, listener } = setupWithListener(method) 69 | emit() 70 | 71 | t.true(listener.calledOnce) 72 | }, (_, method) => `${method}(event, listener) registers the listener for the event`) 73 | 74 | const callsListenerOnTarget = macro((t, method) => { 75 | const { emit, listener, target } = setupWithListener(method) 76 | emit() 77 | 78 | const { thisValue } = listener.firstCall 79 | t.true(thisValue === target) 80 | }, (_, method) => `listener for ${method}() is called on the target object`) 81 | 82 | const repeatListenerRegistration = macro((t, method) => { 83 | const { emit, emitter, event, listener } = setupWithListener(method) 84 | emitter[method](event, listener) 85 | emit() 86 | 87 | t.true(listener.calledOnce) 88 | }, (_, method) => `listener is called once even if registered more than once for the same event with ${method}()`) 89 | 90 | const multipleListenerRegistration = macro((t, method) => { 91 | const { emitter, event, listener } = setupWithListener(method) 92 | const otherEvent = Symbol() 93 | emitter[method](otherEvent, listener) 94 | 95 | const [first, second] = [Symbol(), Symbol()] 96 | emitter.emit(event, first) 97 | emitter.emit(otherEvent, second) 98 | 99 | t.true(listener.calledTwice) 100 | const { args: [[firstValue], [secondValue]] } = listener 101 | t.true(firstValue === first) 102 | t.true(secondValue === second) 103 | }, (_, method) => `listener is called multiple times if registered for multiple events with ${method}()`) 104 | 105 | const repeatEmitOfOnListener = macro((t, previous) => { 106 | const { emit, emitter, event, listener } = setupWithListener(previous || 'on') 107 | if (previous) { 108 | emitter[previous === 'on' ? 'once' : 'on'](event, listener) 109 | } 110 | 111 | ;[Symbol(), Symbol(), Symbol()].forEach((arg, n) => { 112 | emit(arg) 113 | 114 | t.true(listener.callCount === n + 1) 115 | const { args: [value] } = listener.getCall(n) 116 | t.true(value === arg) 117 | }) 118 | }, (_, previous) => { 119 | const suffixes = { 120 | '': 'on()', 121 | on: 'once(), if previously registered with on()', 122 | once: 'on(), even if previously registered with once()' 123 | } 124 | 125 | return `listener is called multiple times if registered with ${suffixes[previous]}` 126 | }) 127 | 128 | const removesListener = macro((t, method) => { 129 | const { emit, emitter, event, listener } = setupWithListener(method) 130 | emitter.removeListener(event, listener) 131 | emit() 132 | 133 | t.true(listener.notCalled) 134 | }, (_, method) => `removeListener(event, listener) removes listener previously registered for event with ${method}()`) 135 | 136 | test.always.afterEach(() => { 137 | stubbedProcess.nextTick.reset() 138 | }) 139 | 140 | test('exposeEvents() returns an emitter', t => { 141 | const emitter = exposeEvents({}) 142 | t.true(typeof emitter.emit === 'function') 143 | }) 144 | 145 | for (const method of ['on', 'once', 'removeListener']) { 146 | test(checkDefinition, method) 147 | test(propagatesArgs, method) 148 | test(returnsThisArg, method) 149 | test(listenerMustBeFunction, method) 150 | } 151 | 152 | for (const method of ['on', 'once']) { 153 | test(registersListener, method) 154 | test(callsListenerOnTarget, method) 155 | test(repeatListenerRegistration, method) 156 | test(multipleListenerRegistration, method) 157 | test(removesListener, method) 158 | } 159 | 160 | test(repeatEmitOfOnListener, '') 161 | test(repeatEmitOfOnListener, 'on') 162 | test(repeatEmitOfOnListener, 'once') 163 | 164 | test('listener is called once if registered with once()', t => { 165 | const { emit, listener } = setupWithListener('once') 166 | const first = Symbol() 167 | ;[first, Symbol(), Symbol()].forEach((arg, n) => { 168 | emit(arg) 169 | }) 170 | 171 | t.true(listener.calledOnce) 172 | const { args: [value] } = listener.firstCall 173 | t.true(value === first) 174 | }) 175 | 176 | test('once() listeners are called in order of registration', t => { 177 | const { emit, emitter, event, listener } = setupWithListener('once') 178 | const otherListener = spy() 179 | emitter.once(event, otherListener) 180 | emit() 181 | 182 | t.true(listener.calledBefore(otherListener)) 183 | }) 184 | 185 | test('removeListener(event, listener) has no effect if listener wasn\'t registered for the event', t => { 186 | const { emitter, event } = setupWithListener('on') 187 | emitter.removeListener(event, () => {}) 188 | t.pass() 189 | }) 190 | 191 | test('removeListener(event, listener) has no effect if no listener was registered for the event', t => { 192 | const { emitter } = setupWithListener('on') 193 | emitter.removeListener(Symbol(), () => {}) 194 | t.pass() 195 | }) 196 | 197 | test('removeListener(event, listener) does not remove other listeners', t => { 198 | const { emit, emitter, event, listener } = setupWithListener('on') 199 | emitter.removeListener(event, () => {}) 200 | emit() 201 | 202 | t.true(listener.calledOnce) 203 | }) 204 | 205 | test('call listeners that are added while emitting', t => { 206 | const { emit, emitter, event } = setupWithListener('on') 207 | const listener = spy() 208 | emitter.on(event, () => { 209 | emitter.on(event, listener) 210 | }) 211 | emit() 212 | 213 | t.true(listener.calledOnce) 214 | }) 215 | 216 | test('does not call listeners that are removed while emitting', t => { 217 | const { emit, emitter, event } = setupWithListener('on') 218 | const listener = spy() 219 | emitter.on(event, () => { 220 | emitter.removeListener(event, listener) 221 | }) 222 | emitter.on(event, listener) 223 | emit() 224 | 225 | t.true(listener.notCalled) 226 | }) 227 | 228 | test('does not call the next listeners if a listener throws', t => { 229 | const { emit, emitter, event } = setupWithListener('on') 230 | const listener = spy() 231 | emitter.on(event, () => { 232 | throw new Error() 233 | }) 234 | emitter.on(event, listener) 235 | emit() 236 | 237 | t.true(listener.notCalled) 238 | }) 239 | 240 | test('asynchronously rethrows listener errors', t => { 241 | const { emit, emitter, event } = setupWithListener('on') 242 | const expected = new Error() 243 | emitter.on(event, () => { 244 | throw expected 245 | }) 246 | 247 | t.true(stubbedProcess.nextTick.notCalled) 248 | emit() 249 | t.true(stubbedProcess.nextTick.calledOnce) 250 | 251 | const actual = t.throws(() => stubbedProcess.nextTick.yield()) 252 | t.true(actual === expected) 253 | }) 254 | -------------------------------------------------------------------------------- /test/lib/helpers/dist.js: -------------------------------------------------------------------------------- 1 | const { join } = require('path') 2 | 3 | module.exports = mid => join(__dirname, '..', '..', '..', 'dist', mid) 4 | -------------------------------------------------------------------------------- /test/lib/helpers/fork-context.js: -------------------------------------------------------------------------------- 1 | const stateMap = new WeakMap() 2 | 3 | function runHooks (hooks, select, t) { 4 | const queue = hooks.map(hook => hook[select]).filter(Boolean) 5 | const next = () => { 6 | while (queue.length) { 7 | const fn = queue.shift() 8 | const promise = fn(t) 9 | if (promise && typeof promise.then === 'function') { 10 | return promise.then(next) 11 | } 12 | } 13 | } 14 | return next() 15 | } 16 | 17 | const serialGetter = function () { 18 | const state = stateMap.get(this) 19 | const { serial: wasSerial } = state 20 | 21 | const test = (...args) => { 22 | try { 23 | state.serial = true 24 | this.test(...args) 25 | } finally { 26 | state.serial = wasSerial 27 | } 28 | } 29 | 30 | test.serial = test 31 | test.test = (...args) => test(...args) 32 | 33 | return test 34 | } 35 | 36 | class Context { 37 | constructor (test, hooks = [], serial = false) { 38 | stateMap.set(this, { test, hooks, serial }) 39 | this.test = this.test.bind(this) 40 | 41 | Object.defineProperty(this.test, 'serial', { 42 | get: serialGetter.bind(this) 43 | }) 44 | } 45 | 46 | get always () { 47 | return { 48 | afterEach: fn => { 49 | stateMap.get(this).hooks.push({ alwaysAfterEach: fn }) 50 | return this 51 | } 52 | } 53 | } 54 | 55 | get serial () { 56 | return serialGetter.call(this) 57 | } 58 | 59 | fork ({ serial: forceSerial = false } = {}) { 60 | const { test, hooks, serial } = stateMap.get(this) 61 | return new Context(test, hooks.slice(), forceSerial || serial) 62 | } 63 | 64 | beforeEach (fn) { 65 | stateMap.get(this).hooks.push({ beforeEach: fn }) 66 | return this 67 | } 68 | 69 | afterEach (fn) { 70 | stateMap.get(this).push({ afterEach: fn }) 71 | return this 72 | } 73 | 74 | test (...args) { 75 | const { test, hooks, serial } = stateMap.get(this) 76 | 77 | const fnIx = typeof args[0] === 'function' ? 0 : 1 78 | const fn = args[fnIx] 79 | args[fnIx] = (t, ...args) => { 80 | const runAfterEach = () => runHooks(hooks, 'afterEach', t) 81 | const runAlwaysAfterEach = () => runHooks(hooks, 'alwaysAfterEach', t) 82 | const runAlwaysAfterEachWithRethrow = err => { 83 | return Promise.resolve(runAlwaysAfterEach).then(() => { 84 | throw err 85 | }) 86 | } 87 | 88 | const beforeEachPromise = runHooks(hooks, 'beforeEach', t) 89 | if (beforeEachPromise && typeof beforeEachPromise.then === 'function') { 90 | return beforeEachPromise 91 | .then(() => fn(t, ...args)) 92 | .then(runAfterEach) 93 | .then(runAlwaysAfterEach, runAlwaysAfterEachWithRethrow) 94 | } 95 | 96 | let promise 97 | try { 98 | const runPromise = fn(t, ...args) 99 | if (runPromise && typeof runPromise.then === 'function') { 100 | promise = runPromise 101 | .then(runAfterEach) 102 | .then(runAlwaysAfterEach, runAlwaysAfterEachWithRethrow) 103 | } else { 104 | // FIXME: Ensure this doesn't run if an assertion fails 105 | const afterEachPromise = runAfterEach() 106 | if (afterEachPromise && typeof afterEachPromise.then === 'function') { 107 | promise = afterEachPromise.then(runAlwaysAfterEach, runAlwaysAfterEachWithRethrow) 108 | } 109 | } 110 | } finally { 111 | if (!promise) { 112 | promise = runAlwaysAfterEach() 113 | } 114 | } 115 | 116 | return promise 117 | } 118 | 119 | if (fn.title) { 120 | if (fnIx === 1) { 121 | args[0] = fn.title(args[0], ...args.slice(2)) 122 | } else { 123 | args.unshift(fn.title('', ...args.slice(1))) 124 | } 125 | } 126 | 127 | if (serial) { 128 | test.serial(...args) 129 | } else { 130 | test(...args) 131 | } 132 | } 133 | } 134 | 135 | const rootContext = new Context(require('ava')) 136 | module.exports = rootContext.fork.bind(rootContext) 137 | -------------------------------------------------------------------------------- /test/lib/helpers/macro.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = function macro (implementation, title) { 4 | const macro = function (...args) { 5 | return implementation(...args) 6 | } 7 | macro.title = title 8 | return macro 9 | } 10 | -------------------------------------------------------------------------------- /test/lib/helpers/role-tests.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable ava/no-ignored-test-files */ 2 | const test = require('ava') 3 | const proxyquire = require('proxyquire') 4 | const { spy, stub } = require('sinon') 5 | 6 | const dist = require('./dist') 7 | 8 | const { default: ActualInputConsumer } = require(dist('lib/InputConsumer')) 9 | const { default: ActualScheduler } = require(dist('lib/Scheduler')) 10 | 11 | function setupConstructors (roleSource) { 12 | const shared = { 13 | InputConsumer () {}, 14 | Scheduler () {} 15 | } 16 | 17 | const { default: Role } = proxyquire.noCallThru()(roleSource, { 18 | '../InputConsumer': function (...args) { return shared.InputConsumer(...args) }, 19 | '../Scheduler': function (...args) { return shared.Scheduler(...args) } 20 | }) 21 | 22 | test.beforeEach(t => { 23 | const InputConsumer = spy(function (...args) { return new ActualInputConsumer(...args) }) 24 | const Scheduler = spy(function (...args) { return new ActualScheduler(...args) }) 25 | 26 | // Note that the next tests' beforeEach hook overrides the shared stubs. 27 | // Tests where these classes are instantiated /*async*/hronously need to be 28 | // marked as serial. 29 | Object.assign(shared, { InputConsumer, Scheduler }) 30 | 31 | Object.assign(t.context, { InputConsumer, Scheduler }) 32 | }) 33 | 34 | return Role 35 | } 36 | 37 | function testFollowerConversion (type) { 38 | const setup = context => { 39 | const { [type]: role, state } = context 40 | state._currentTerm.returns(1) 41 | const message = { term: 2 } 42 | return Object.assign({ message, role }, context) 43 | } 44 | 45 | test('handleMessage() sets the term to that of the message, if it has a newer term', t => { 46 | const { message, peers: [peer], role, state } = setup(t.context) 47 | role.handleMessage(peer, message) 48 | t.true(state.setTerm.calledOnce) 49 | const { args: [term] } = state.setTerm.firstCall 50 | t.true(term === 2) 51 | }) 52 | 53 | test('handleMessage() returns a promise if the message has a newer term', t => { 54 | const { message, peers: [peer], role } = setup(t.context) 55 | t.true(role.handleMessage(peer, message) instanceof Promise) 56 | }) 57 | 58 | test(`if the role was destroyed while persisting the state, handleMessage() won’t convert the ${type} to follower if the message has a newer term`, t => { 59 | const { convertToFollower, message, peers: [peer], role, state } = setup(t.context) 60 | let persisted 61 | state.setTerm.returns(new Promise(resolve => { 62 | persisted = resolve 63 | })) 64 | 65 | role.handleMessage(peer, message) 66 | role.destroy() 67 | persisted() 68 | 69 | return Promise.resolve().then(() => { 70 | t.true(convertToFollower.notCalled) 71 | return 72 | }) 73 | }) 74 | 75 | test(`handleMessage() converts the ${type} to follower if the message has a newer term`, t => { 76 | const { convertToFollower, message, peers: [peer], role } = setup(t.context) 77 | return role.handleMessage(peer, message).then(() => { 78 | t.true(convertToFollower.calledOnce) 79 | const { args: [[receivedPeer, receivedMessage]] } = convertToFollower.firstCall 80 | t.true(receivedPeer === peer) 81 | t.true(receivedMessage === message) 82 | return 83 | }) 84 | }) 85 | } 86 | 87 | function testInputConsumerDestruction (type) { 88 | test('destroy() stops the input consumer', t => { 89 | const { [type]: role } = t.context 90 | spy(role.inputConsumer, 'stop') 91 | role.destroy() 92 | t.true(role.inputConsumer.stop.calledOnce) 93 | }) 94 | } 95 | 96 | function testInputConsumerInstantiation (type) { 97 | test('instantiate an input consumer', t => { 98 | const { [type]: role, crashHandler, InputConsumer, peers, nonPeerReceiver } = t.context 99 | 100 | t.true(role.inputConsumer instanceof ActualInputConsumer) 101 | t.true(InputConsumer.calledOnce) 102 | const { args: [{ peers: receivedPeers, nonPeerReceiver: receivedNonPeerReceiver, scheduler, handleMessage, crashHandler: receivedCrashHandler }] } = InputConsumer.firstCall 103 | t.true(receivedPeers === peers) 104 | t.true(receivedNonPeerReceiver === nonPeerReceiver) 105 | t.true(scheduler === role.scheduler) 106 | t.true(typeof handleMessage === 'function') 107 | t.true(receivedCrashHandler === crashHandler) 108 | }) 109 | 110 | test(`input consumer calls handleMessage() on the ${type} when it reads a message`, t => { 111 | const { [type]: role, peers: [peer] } = t.context 112 | // Ensure message can be read 113 | const message = Symbol() 114 | peer.messages.take.onCall(0).returns(message) 115 | peer.messages.canTake.onCall(0).returns(true) 116 | 117 | const handleMessage = stub(role, 'handleMessage') 118 | role.inputConsumer.start() 119 | 120 | t.true(handleMessage.calledOnce) 121 | t.true(handleMessage.calledOn(role)) 122 | const { args: [receivedPeer, handledMessage] } = handleMessage.firstCall 123 | t.true(receivedPeer === peer) 124 | t.true(handledMessage === message) 125 | }) 126 | } 127 | 128 | function testInputConsumerStart (type) { 129 | test('start() starts the input consumer', t => { 130 | const { [type]: role } = t.context 131 | spy(role.inputConsumer, 'start') 132 | role.start() 133 | t.true(role.inputConsumer.start.calledOnce) 134 | }) 135 | } 136 | 137 | function testMessageHandlerMapping (type, mapping) { 138 | for (const { type: messageType, label, method } of mapping) { 139 | test(`handleMessage() calls ${method}() with the peer, the message’s term, and the message itself, if the message type is ${label}`, t => { 140 | const { [type]: role, peers: [peer] } = t.context 141 | const methodStub = stub(role, method) 142 | const message = { type: messageType, term: 1 } 143 | role.handleMessage(peer, message) 144 | 145 | t.true(methodStub.calledOnce) 146 | const { args: [mappedPeer, mappedTerm, mappedMessage] } = methodStub.firstCall 147 | t.true(mappedPeer === peer) 148 | t.true(mappedTerm === message.term) 149 | t.true(mappedMessage === message) 150 | }) 151 | 152 | test(`handleMessage() returns the result of calling ${method}()`, t => { 153 | const { [type]: role, peers: [peer] } = t.context 154 | const result = Symbol() 155 | stub(role, method).returns(result) 156 | t.true(role.handleMessage(peer, { type: messageType, term: 1 }) === result) 157 | }) 158 | } 159 | 160 | test('handleMessage() ignores unknown message types', t => { 161 | const { [type]: role, peers: [peer] } = t.context 162 | t.true(role.handleMessage(peer, { type: Symbol(), term: 1 }) === undefined) 163 | }) 164 | } 165 | 166 | function testSchedulerDestruction (type) { 167 | test('destroy() aborts the scheduler', t => { 168 | const { [type]: role } = t.context 169 | spy(role.scheduler, 'abort') 170 | role.destroy() 171 | t.true(role.scheduler.abort.calledOnce) 172 | }) 173 | } 174 | 175 | function testSchedulerInstantiation (type) { 176 | test('instantiate a scheduler', t => { 177 | const { [type]: role, crashHandler, Scheduler } = t.context 178 | 179 | t.true(role.scheduler instanceof ActualScheduler) 180 | t.true(Scheduler.calledOnce) 181 | const { args: [receivedCrashHandler] } = Scheduler.firstCall 182 | t.true(receivedCrashHandler === crashHandler) 183 | }) 184 | } 185 | 186 | Object.assign(exports, { 187 | setupConstructors, 188 | testFollowerConversion, 189 | testInputConsumerDestruction, 190 | testInputConsumerInstantiation, 191 | testInputConsumerStart, 192 | testMessageHandlerMapping, 193 | testSchedulerDestruction, 194 | testSchedulerInstantiation 195 | }) 196 | -------------------------------------------------------------------------------- /test/lib/helpers/stub-helpers.js: -------------------------------------------------------------------------------- 1 | const { install: installClock } = require('lolex') 2 | const { stub } = require('sinon') 3 | 4 | const dist = require('./dist') 5 | 6 | const { default: Timers } = require(dist('lib/Timers')) 7 | 8 | function stubLog () { 9 | const log = stub({ 10 | _lastIndex () {}, 11 | get lastIndex () { return this._lastIndex() }, 12 | _lastTerm () {}, 13 | get lastTerm () { return this._lastTerm() }, 14 | appendValue () {}, 15 | commit () {}, 16 | getEntry () {}, 17 | getTerm () {}, 18 | mergeEntries () {}, 19 | getEntriesSince () {}, 20 | checkOutdated () {} 21 | }) 22 | log._lastIndex.returns(0) 23 | log.getEntry.returns(undefined) 24 | log.mergeEntries.returns(Promise.resolve()) 25 | 26 | log.appendValue.throws(new Error('appendValue() stub must be customized')) 27 | log.getTerm.throws(new Error('getTerm() stub must be customized')) 28 | log.getEntriesSince.throws(new Error('getEntriesSince() stub must be customized')) 29 | 30 | log.checkOutdated.returns(false) 31 | 32 | return log 33 | } 34 | 35 | function stubMessages () { 36 | const messages = stub({ canTake () {}, take () {}, await () {} }) 37 | messages.canTake.returns(false) 38 | messages.take.returns(null) 39 | messages.await.returns(new Promise(() => {})) 40 | return messages 41 | } 42 | 43 | let peerCount = 0 44 | function stubPeer () { 45 | return stub({ messages: stubMessages(), send () {}, id: ++peerCount }) 46 | } 47 | 48 | function stubState () { 49 | const state = stub({ 50 | _currentTerm () {}, 51 | get currentTerm () { return this._currentTerm() }, 52 | _votedFor () {}, 53 | get votedFor () { return this._votedFor() }, 54 | nextTerm () {}, 55 | replace () {}, 56 | setTerm () {}, 57 | setTermAndVote () {} 58 | }) 59 | state._currentTerm.returns(0) 60 | state._votedFor.returns(null) 61 | state.nextTerm.returns(Promise.resolve()) 62 | state.setTerm.returns(Promise.resolve()) 63 | state.setTermAndVote.returns(Promise.resolve()) 64 | return state 65 | } 66 | 67 | function stubTimers () { 68 | const timers = new Timers() 69 | const clock = installClock(timers, 0, ['clearInterval', 'setInterval', 'clearTimeout', 'setTimeout']) 70 | return { clock, timers } 71 | } 72 | 73 | Object.assign(exports, { 74 | stubLog, 75 | stubMessages, 76 | stubPeer, 77 | stubState, 78 | stubTimers 79 | }) 80 | -------------------------------------------------------------------------------- /test/lib/main.js: -------------------------------------------------------------------------------- 1 | import test from 'ava' 2 | import proxyquire from 'proxyquire' 3 | import { spy, stub } from 'sinon' 4 | 5 | import { 6 | AppendEntries, AcceptEntries, RejectEntries, 7 | RequestVote, DenyVote, GrantVote, 8 | Noop 9 | } from 'dist/lib/symbols' 10 | import Address from 'dist/lib/Address' 11 | import Entry from 'dist/lib/Entry' 12 | 13 | import dist from './helpers/dist' 14 | import macro from './helpers/macro' 15 | 16 | // Don't use the Promise introduced by babel-runtime. https://github.com/avajs/ava/issues/947 17 | const { Promise } = global 18 | 19 | const shared = { 20 | Server () {} 21 | } 22 | 23 | const main = proxyquire.noCallThru()(dist('lib/main'), { 24 | './Server': function (...args) { return shared.Server(...args) } 25 | }) 26 | 27 | const throwsTypeError = macro((t, param, value, message) => { 28 | const { createServer } = t.context 29 | t.throws(() => createServer({ [param]: value }), TypeError, message) 30 | }, (condition, param) => `createServer() throws a TypeError if the ${param} param is ${condition}`) 31 | 32 | const usingParam = macro((t, param, value) => { 33 | const { createServer, Server } = t.context 34 | createServer({ [param]: value }) 35 | t.true(Server.calledOnce) 36 | const { args: [{ [param]: used }] } = Server.firstCall 37 | t.true(used === value) 38 | }, (condition, param) => `createServer() creates a server using the ${param} param ${condition}`.trim()) 39 | 40 | const usingWrapper = macro((t, param) => { 41 | const { createServer, Server } = t.context 42 | createServer({ [param] () {} }) 43 | t.true(Server.calledOnce) 44 | const { args: [{ [param]: wrapper }] } = Server.firstCall 45 | t.true(typeof wrapper === 'function') 46 | }, (_, param) => `createServer() creates a server with a Promise-wrapper for the ${param} param`) 47 | 48 | function setupWrapper (context, param) { 49 | const { createServer, Server } = context 50 | const original = stub() 51 | createServer({ [param]: original }) 52 | const { args: [{ [param]: wrapper }] } = Server.firstCall 53 | return Object.assign({ original, wrapper }, context) 54 | } 55 | 56 | const wrapperPropagatesToOriginal = macro((t, param) => { 57 | const { original, wrapper } = setupWrapper(t.context, param) 58 | const args = Array.from(wrapper, () => Symbol()) 59 | wrapper(...args) 60 | 61 | t.true(original.calledOnce) 62 | const { args: propagated } = original.firstCall 63 | t.deepEqual(propagated, args) 64 | }, (_, param) => `the Promise-wrapper for the ${param} param propagates to the original function`) 65 | 66 | const wrapperFulfilsLikeOriginal = macro(async (t, param) => { 67 | const { original, wrapper } = setupWrapper(t.context, param) 68 | const value = Symbol() 69 | original.returns(Promise.resolve(value)) 70 | t.true(await wrapper() === value) 71 | }, (_, param) => `the Promise-wrapper for the ${param} param returns a promise that is fulfilled with the value of a promise returned by the original function`) 72 | 73 | const wrapperRejectsLikeOriginal = macro(async (t, param) => { 74 | const { original, wrapper } = setupWrapper(t.context, param) 75 | const err = new Error() 76 | original.returns(Promise.reject(err)) 77 | const actualErr = await t.throws(wrapper()) 78 | t.true(actualErr === err) 79 | }, (_, param) => `the Promise-wrapper for the ${param} param returns a promise that is rejected with the reason of a promise returned by the original function`) 80 | 81 | const wrapperFulfilsWithOriginalResult = macro(async (t, param) => { 82 | const { original, wrapper } = setupWrapper(t.context, param) 83 | const value = Symbol() 84 | original.returns(value) 85 | t.true(await wrapper() === value) 86 | }, (_, param) => `the Promise-wrapper for the ${param} param returns a promise that is fulfilled with the value returned by the original function`) 87 | 88 | const wrapperRejectsWithOriginalException = macro(async (t, param) => { 89 | const { original, wrapper } = setupWrapper(t.context, param) 90 | const err = new Error() 91 | original.throws(err) 92 | const actualErr = await t.throws(wrapper()) 93 | t.true(actualErr === err) 94 | }, (_, param) => `the Promise-wrapper for the ${param} param returns a promise that is rejected with the exception thrown by the original function`) 95 | 96 | test.beforeEach(t => { 97 | const Server = spy(() => stub()) 98 | 99 | const createServer = opts => { 100 | return main.createServer(Object.assign({ 101 | address: '///id', 102 | electionTimeoutWindow: [1000, 2000], 103 | heartbeatInterval: 500, 104 | createTransport () {}, 105 | persistState () {}, 106 | persistEntries () {}, 107 | applyEntry () {}, 108 | crashHandler () {} 109 | }, opts)) 110 | } 111 | 112 | // Note that the next tests' beforeEach hook overrides the shared Server stub. 113 | // Tests where the Server is instantiated asynchronously need to be marked as 114 | // serial. 115 | Object.assign(shared, { Server }) 116 | 117 | Object.assign(t.context, { 118 | createServer, 119 | Server 120 | }) 121 | }) 122 | 123 | test('export symbols', t => { 124 | t.deepEqual(main.symbols, { 125 | AppendEntries, AcceptEntries, RejectEntries, 126 | RequestVote, DenyVote, GrantVote, 127 | Noop 128 | }) 129 | }) 130 | 131 | test('export Address', t => { 132 | t.true(main.Address === Address) 133 | }) 134 | 135 | test('export Entry', t => { 136 | t.true(main.Entry === Entry) 137 | }) 138 | 139 | { 140 | const message = "Parameter 'address' must be a string or an Address instance" 141 | test('an invalid address string', throwsTypeError, 'address', '🙈', message) 142 | test('not a string or an Address instance', throwsTypeError, 'address', 42, message) 143 | } 144 | 145 | test('createServer() creates the server with an Address instance if the address param is a valid address string', t => { 146 | const { createServer, Server } = t.context 147 | createServer({ address: '///foo' }) 148 | t.true(Server.calledOnce) 149 | const { args: [{ address }] } = Server.firstCall 150 | t.true(address instanceof Address) 151 | t.true(address.serverId === 'foo') 152 | }) 153 | 154 | test('if it is an Address instance', usingParam, 'address', new Address('///foo')) 155 | 156 | test('createServer() creates a server whose id is the serverId of the address', t => { 157 | const { createServer, Server } = t.context 158 | const address = new Address('///foo') 159 | createServer({ address }) 160 | t.true(Server.calledOnce) 161 | const { args: [{ id }] } = Server.firstCall 162 | t.true(id === address.serverId) 163 | }) 164 | 165 | for (const [condition, value, message] of [ 166 | ['not iterable', true, "Parameter 'electionTimeoutWindow' must be iterable"], 167 | ...[ 168 | ['not an integer', '🙈', "Values of parameter 'electionTimeoutWindow' must be integers"], 169 | ['less than zero', -1, "First value of parameter 'electionTimeoutWindow' must be greater than zero"], 170 | ['0', 0, "First value of parameter 'electionTimeoutWindow' must be greater than zero"], 171 | ['larger than the second value', 11, "Second value of parameter 'electionTimeoutWindow' must be greater than the first"], 172 | ['equal to the second value', 10, "Second value of parameter 'electionTimeoutWindow' must be greater than the first"] 173 | ].map(([condition, value, message]) => [`an iterable whose first value is ${condition}`, [value, 10], message]), 174 | ['an iterable whose second value is not an integer', '🙈', "Values of parameter 'electionTimeoutWindow' must be integers"] 175 | ]) { 176 | test(condition, throwsTypeError, 'electionTimeoutWindow', value, message) 177 | } 178 | 179 | test(usingParam, 'electionTimeoutWindow', [10, 20]) 180 | 181 | for (const [condition, value, message] of [ 182 | ['not an integer', '🙈'], 183 | ['less than zero', -1], 184 | ['0', 0] 185 | ]) { 186 | test(condition, throwsTypeError, 'heartbeatInterval', value, message) 187 | } 188 | 189 | test(usingParam, 'heartbeatInterval', 5) 190 | 191 | for (const param of [ 192 | 'createTransport', 193 | 'persistState', 194 | 'persistEntries', 195 | 'applyEntry', 196 | 'crashHandler' 197 | ]) { 198 | test('not a function', throwsTypeError, param, 42, `Parameter '${param}' must be a function, not number`) 199 | } 200 | 201 | test(usingParam, 'createTransport', () => {}) 202 | test(usingParam, 'crashHandler', () => {}) 203 | 204 | for (const param of ['applyEntry', 'persistEntries', 'persistState']) { 205 | test(usingWrapper, param) 206 | test(wrapperPropagatesToOriginal, param) 207 | test(wrapperFulfilsLikeOriginal, param) 208 | test(wrapperRejectsLikeOriginal, param) 209 | test(wrapperFulfilsWithOriginalResult, param) 210 | test(wrapperRejectsWithOriginalException, param) 211 | } 212 | --------------------------------------------------------------------------------