Skip to content

Commit

Permalink
Tests and fixes
Browse files Browse the repository at this point in the history
  • Loading branch information
henokgetachew committed Oct 16, 2024
1 parent c5d2f71 commit 8b11ba9
Show file tree
Hide file tree
Showing 6 changed files with 297 additions and 44 deletions.
20 changes: 14 additions & 6 deletions bin/move-node.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,23 @@ const [,, toNode, shardMapJson] = process.argv;
const { moveNode, syncShards } = require('../src/move-node');
const { removeNode } = require('../src/remove-node');

const parseNodeMapping = function (input) {
const parseNodeMapping = (input) => {
if (!input) {
return undefined;
//single node migration with unspecified node
return;
} else if (input.startsWith('{')) {
return JSON.parse(input);
} else if (input.includes(':')) {
const [oldNode, newNode] = input.split(':');
return { [oldNode]: newNode };
//multi-node migration with specified node mapping
//Example input: '{"oldNode":"newNode"}'
try {
return JSON.parse(input);
}
catch (err) {
throw new Error(
`Invalid node mapping. Please specify the node mapping in the format '{"<oldNode>":"<newNode>"}'`
);
}
}
//single node migration with specified node
return input;
};

Expand Down
4 changes: 0 additions & 4 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,6 @@ version: '3.9'
services:
couch-migration:
image: public.ecr.aws/medic/couchdb-migration:1.0.4
# use local image
#build:
# context: .
# dockerfile: Dockerfile
networks:
- cht-net
environment:
Expand Down
74 changes: 43 additions & 31 deletions src/move-node.js
Original file line number Diff line number Diff line change
@@ -1,44 +1,56 @@
const utils = require('./utils');
const moveShard = require('./move-shard');

const moveNode = async (toNode, shardMapJson) => {
const replaceNodeInStandAloneCouch = async (toNode) => {
const removedNodes = [];

if (typeof toNode === 'object') {
// Multi-node migration
if (!shardMapJson) {
throw new Error('Shard map JSON is required for multi-node migration');
}
const shardMap = JSON.parse(shardMapJson);
const [oldNode, newNode] = Object.entries(toNode)[0];
console.log(`Migrating from ${oldNode} to ${newNode}`);

for (const [shardRange, currentNode] of Object.entries(shardMap)) {
if (currentNode === oldNode) {
console.log(`Moving shard ${shardRange} from ${oldNode} to ${newNode}`);
const oldNodes = await moveShard.moveShard(shardRange, newNode);
removedNodes.push(...oldNodes);
}
}
if (!removedNodes.includes(oldNode)) {
removedNodes.push(oldNode);
}
} else {
// Single node migration
if (!toNode) {
const nodes = await utils.getNodes();
if (nodes.length > 1) {
throw new Error('More than one node found. Please specify a node mapping in the format oldNode:newNode.');
}
toNode = nodes[0];
if (!toNode) {
const nodes = await utils.getNodes();
if (nodes.length > 1) {
throw new Error('More than one node found. Please specify a node mapping in the format oldNode:newNode.');
}
toNode = nodes[0];
}

const shards = await utils.getShards();
for (const shard of shards) {
const oldNodes = await moveShard.moveShard(shard, toNode);
removedNodes.push(...oldNodes);
}
return removedNodes;
};

const shards = await utils.getShards();
for (const shard of shards) {
const oldNodes = await moveShard.moveShard(shard, toNode);
const replaceNodeInClusteredCouch = async (toNode, shardMapJson) => {
const removedNodes = [];
if (!shardMapJson) {
throw new Error('Shard map JSON is required for multi-node migration');
}
const shardMap = JSON.parse(shardMapJson);
const [oldNode, newNode] = Object.entries(toNode)[0];
console.log(`Migrating from ${oldNode} to ${newNode}`);

for (const [shardRange, currentNode] of Object.entries(shardMap)) {
if (currentNode === oldNode) {
console.log(`Moving shard ${shardRange} from ${oldNode} to ${newNode}`);
const oldNodes = await moveShard.moveShard(shardRange, newNode);
removedNodes.push(...oldNodes);
}
}
if (!removedNodes.includes(oldNode)) {
removedNodes.push(oldNode);
}
return removedNodes;
};

const moveNode = async (toNode, shardMapJson) => {
let removedNodes = [];
if (typeof toNode === 'object') {
// Multi-node migration - replace node in clustered couch
removedNodes = await replaceNodeInClusteredCouch(toNode, shardMapJson);
} else {
// Single node migration - replace node in standalone couch
removedNodes = await replaceNodeInStandAloneCouch(toNode);
}

return [...new Set(removedNodes)];
};
Expand Down
6 changes: 3 additions & 3 deletions test/e2e/multi-node.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ docker compose -f ../docker-compose-test.yml run couch-migration check-couchdb-u

# change database metadata to match new node name
# Move nodes one by one using move-node.js oldNode:newNode
docker compose -f ../docker-compose-test.yml run couch-migration move-node [email protected]:[email protected] $shard_mapping
docker compose -f ../docker-compose-test.yml run couch-migration move-node [email protected]:[email protected] $shard_mapping
docker compose -f ../docker-compose-test.yml run couch-migration move-node [email protected]:[email protected] $shard_mapping
docker compose -f ../docker-compose-test.yml run couch-migration move-node '{"[email protected]":"[email protected]"}' $shard_mapping
docker compose -f ../docker-compose-test.yml run couch-migration move-node '{"[email protected]":"[email protected]"}' $shard_mapping
docker compose -f ../docker-compose-test.yml run couch-migration move-node '{"[email protected]":"[email protected]"}' $shard_mapping

docker compose -f ../docker-compose-test.yml run couch-migration verify

Expand Down
188 changes: 188 additions & 0 deletions test/unit/get-shard-mapping.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
const { expect } = require('chai');
const sinon = require('sinon');
const utils = require('../../src/utils');
const { get_shard_mapping } = require('../../src/get-shard-mapping');

describe('get_shard_mapping', () => {
let consoleWarnStub;
let consoleErrorStub;

beforeEach(() => {
consoleWarnStub = sinon.stub(console, 'warn');
consoleErrorStub = sinon.stub(console, 'error');
});

afterEach(() => {
sinon.restore();
});

describe('error handling', () => {
it('should throw an error when utils.getDbs fails', async () => {
sinon.stub(utils, 'getDbs').rejects(new Error('Failed to get DBs'));

try {
await get_shard_mapping();
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Failed to get shard mapping');
expect(consoleErrorStub.calledOnce).to.be.true;
expect(consoleErrorStub.firstCall.args[0]).to.equal('Error getting shard mapping:');
}
});

it('should throw an error when utils.getUrl fails', async () => {
sinon.stub(utils, 'getDbs').resolves(['medic', 'medic-sentinel', 'medic-logs']);
sinon.stub(utils, 'getUrl').rejects(new Error('Failed to get URL'));

try {
await get_shard_mapping();
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Failed to get shard mapping');
expect(consoleErrorStub.calledOnce).to.be.true;
expect(consoleErrorStub.firstCall.args[0]).to.equal('Error getting shard mapping:');
}
});

it('should throw an error when utils.request fails', async () => {
sinon.stub(utils, 'getDbs').resolves(['medic', 'medic-sentinel', 'medic-logs']);
sinon.stub(utils, 'getUrl').resolves('http://admin:[email protected]:5984/medic/_shards');
sinon.stub(utils, 'request').rejects(new Error('Failed to get shard info'));

try {
await get_shard_mapping();
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Failed to get shard mapping');
expect(consoleErrorStub.calledOnce).to.be.true;
expect(consoleErrorStub.firstCall.args[0]).to.equal('Error getting shard mapping:');
}
});
});

describe('functionalities', () => {
it('should handle multiple databases with 8 shards and 3 nodes', async () => {
sinon.stub(utils, 'getDbs').resolves(['medic', 'medic-sentinel', 'medic-logs']);
sinon.stub(utils, 'getUrl')
.onCall(0).resolves('http://admin:[email protected]:5984/medic/_shards')
.onCall(1).resolves('http://admin:[email protected]:5984/medic-sentinel/_shards')
.onCall(2).resolves('http://admin:[email protected]:5984/medic-logs/_shards');

const shardInfo = {
shards: {
'00000000-1fffffff': ['node1'],
'20000000-3fffffff': ['node2'],
'40000000-5fffffff': ['node3'],
'60000000-7fffffff': ['node1'],
'80000000-9fffffff': ['node2'],
'a0000000-bfffffff': ['node3'],
'c0000000-dfffffff': ['node1'],
'e0000000-ffffffff': ['node2']
}
};

sinon.stub(utils, 'request').resolves(shardInfo);

const result = await get_shard_mapping();
const expectedResult = JSON.stringify({
'00000000-1fffffff': 'node1',
'20000000-3fffffff': 'node2',
'40000000-5fffffff': 'node3',
'60000000-7fffffff': 'node1',
'80000000-9fffffff': 'node2',
'a0000000-bfffffff': 'node3',
'c0000000-dfffffff': 'node1',
'e0000000-ffffffff': 'node2'
});

expect(result).to.equal(expectedResult);
expect(utils.getDbs.calledOnce).to.be.true;
expect(utils.getUrl.calledThrice).to.be.true;
expect(utils.request.calledThrice).to.be.true;
});

it('should log a warning when multiple nodes are found for a shard range', async () => {
sinon.stub(utils, 'getDbs').resolves(['medic', 'medic-sentinel']);
sinon.stub(utils, 'getUrl')
.onFirstCall().resolves('http://admin:[email protected]:5984/medic/_shards')
.onSecondCall().resolves('http://admin:[email protected]:5984/medic-sentinel/_shards');
sinon.stub(utils, 'request')
.onFirstCall().resolves({
shards: {
'00000000-1fffffff': ['node1', 'node2']
}
})
.onSecondCall().resolves({
shards: {
'20000000-3fffffff': ['node3']
}
});

await get_shard_mapping();

expect(consoleWarnStub.calledOnce).to.be.true;
expect(consoleWarnStub.firstCall.args[0]).to.include('Unexpected number of nodes for range 00000000-1fffffff: 2');
});

it('should handle null or undefined shard info', async () => {
sinon.stub(utils, 'getDbs').resolves(['medic', 'medic-sentinel', 'medic-logs']);
sinon.stub(utils, 'getUrl').resolves('http://admin:[email protected]:5984/medic/_shards');
sinon.stub(utils, 'request').resolves(null);

try {
await get_shard_mapping();
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Failed to get shard mapping');
expect(consoleErrorStub.calledOnce).to.be.true;
expect(consoleErrorStub.firstCall.args[0]).to.equal('Error getting shard mapping:');
expect(utils.getDbs.calledOnce).to.be.true;
expect(utils.getUrl.calledOnce).to.be.true;
expect(utils.request.calledOnce).to.be.true;
}
});

it('should handle malformed shard info', async () => {
sinon.stub(utils, 'getDbs').resolves(['medic', 'medic-sentinel', 'medic-logs']);
sinon.stub(utils, 'getUrl').resolves('http://admin:[email protected]:5984/medic/_shards');
sinon.stub(utils, 'request').resolves({ invalid: 'data' });

try {
await get_shard_mapping();
expect.fail('Should have thrown an error');
} catch (err) {
expect(err.message).to.equal('Failed to get shard mapping');
expect(consoleErrorStub.calledOnce).to.be.true;
expect(consoleErrorStub.firstCall.args[0]).to.equal('Error getting shard mapping:');
expect(utils.getDbs.calledOnce).to.be.true;
expect(utils.getUrl.calledOnce).to.be.true;
expect(utils.request.calledOnce).to.be.true;
}
});

it('should handle shard info with empty node list', async () => {
const dbs = ['medic', 'medic-sentinel', 'medic-logs'];
sinon.stub(utils, 'getDbs').resolves(dbs);
const getUrlStub = sinon.stub(utils, 'getUrl');
dbs.forEach((db, index) => {
getUrlStub.onCall(index).resolves(`http://admin:[email protected]:5984/${db}/_shards`);
});
sinon.stub(utils, 'request').resolves({
shards: {
'00000000-1fffffff': []
}
});

const result = await get_shard_mapping();

expect(result).to.equal('{}');
expect(utils.getDbs.calledOnce).to.be.true;
expect(utils.getUrl.callCount).to.equal(dbs.length);
expect(utils.request.callCount).to.equal(dbs.length);
expect(consoleWarnStub.callCount).to.equal(dbs.length);
dbs.forEach((db) => {
expect(consoleWarnStub.calledWith(`Unexpected number of nodes for range 00000000-1fffffff: 0`)).to.be.true;
});
});
});
});
49 changes: 49 additions & 0 deletions test/unit/move-node.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,55 @@ describe('move-node', () => {

await expect(moveNodeSpec.moveNode()).to.be.rejectedWith(Error, 'holdupaminute');
});

it('should handle multi-node migration in clustered CouchDB', async () => {
const shardMap = {
'00000000-1fffffff': 'node1',
'20000000-3fffffff': 'node2',
'40000000-5fffffff': 'node1',
'60000000-7fffffff': 'node2'
};
const toNode = { 'node1': 'node3' };
const shardMapJson = JSON.stringify(shardMap);

sinon.stub(moveShard, 'moveShard').resolves(['node1']);
const consoleLogStub = sinon.stub(console, 'log');

const result = await moveNodeSpec.moveNode(toNode, shardMapJson);

expect(result).to.deep.equal(['node1']);
expect(moveShard.moveShard.callCount).to.equal(2);
expect(moveShard.moveShard.args).to.deep.equal([
['00000000-1fffffff', 'node3'],
['40000000-5fffffff', 'node3']
]);
expect(consoleLogStub.calledWith('Migrating from node1 to node3')).to.be.true;
expect(consoleLogStub.calledWith('Moving shard 00000000-1fffffff from node1 to node3')).to.be.true;
expect(consoleLogStub.calledWith('Moving shard 40000000-5fffffff from node1 to node3')).to.be.true;
});

it('should throw an error if shard map JSON is missing in multi-node migration', async () => {
const toNode = { 'node1': 'node3' };

await expect(moveNodeSpec.moveNode(toNode))
.to.be.rejectedWith(Error, 'Shard map JSON is required for multi-node migration');
});

it('should handle case where old node is not in any shard range', async () => {
const shardMap = {
'00000000-1fffffff': 'node2',
'20000000-3fffffff': 'node2'
};
const toNode = { 'node1': 'node3' };
const shardMapJson = JSON.stringify(shardMap);

sinon.stub(moveShard, 'moveShard').resolves([]);

const result = await moveNodeSpec.moveNode(toNode, shardMapJson);

expect(result).to.deep.equal(['node1']);
expect(moveShard.moveShard.callCount).to.equal(0);
});
});

describe('syncShards', () => {
Expand Down

0 comments on commit 8b11ba9

Please sign in to comment.