@boris-turner/amphora-search
Version:
Making it easier to use Elastic Search with Amphora
565 lines (520 loc) • 13.9 kB
JavaScript
;
const elastic = require('elasticsearch'),
helpers = require('./elastic-helpers'),
bluebird = require('bluebird'),
_ = require('lodash'),
endpoint = process.env.ELASTIC_HOST || '',
serverConfig = {
host: endpoint,
maxSockets: 500,
apiVersion: '6.x',
defer: /* istanbul ignore next */ () => bluebird.defer()
},
FIXED_TYPE = '_doc';
var log = require('./log').setup({file: __filename}),
client; // Reference to the ES client
/**
* Log the error with the stacktrace
* @param {Error} err
*/
function logError(err) {
log('error', err.message, { stack: err.stack });
}
/**
* Test whether or not the client is connected
* to Elastic
*
* @param {object} currentClient
* @returns {Promise}
*/
function healthCheck(currentClient) {
return currentClient.ping({
requestTimeout: 1000
}).then(() => {
log('info', 'Elasticsearch cluster is up!');
return bluebird.resolve();
}).catch(error => {
log('error', 'Elasticsearch cluster is down!');
return bluebird.reject(error);
});
}
/**
* Create an Elasticsearch index
*
* @param {string} index
* @param {object} settings
* @returns {Promise}
*/
function initIndex(index, settings) {
return client.indices.create({
index: index,
body: settings || {}
}).then(() => {
log('debug', `Successfully created ${index}`);
})
.catch(error => {
log('error', error);
return bluebird.reject(error);
});
}
/**
* Check if an Elasticsearch index exists
*
* @param {string} index
* @returns {Promise}
*/
function existsIndex(index) {
return client.indices.exists({
index: index
});
}
/**
* CHeck if a document with a given `id` exists
* in the index
*
* @param {string} index
* @param {string} id
* @return {Promise}
*/
function existsDocument(index, id) {
if (!id) {
let err = new Error('Cannot check if document exists without an id');
logError(err);
return bluebird.reject(err);
}
return client.exists({
index: index,
type: FIXED_TYPE,
id: id
});
}
/**
* Indices should have a different name than the alias
* Append '_v1' to an index name.
*
* @param {String} alias
* @param {String} prefix
* @returns {String}
*/
function createIndexName(alias, prefix) {
return `${helpers.indexWithPrefix(alias, prefix)}_v1`;
}
/**
* Create an Elasticsearch alias
*
* @param {string} name, e.g. 'editable-articles' for the index 'editable-articles_v1'
* @param {string} index
* @returns {Promise}
*/
function initAlias(name, index) {
return client.indices.putAlias({
name: name,
index: index
});
}
/**
* Check if an Elasticsearch alias exists
*
* @param {string} name
* @returns {Promise}
*/
function existsAlias(name) {
return client.indices.existsAlias({
name: name
});
}
/**
* Create an Elasticsearch mapping
*
* @param {string} index
* @param {object} mapping
* @returns {Promise}
*/
function initMapping(index, mapping) {
return client.indices.putMapping({
index: index,
type: FIXED_TYPE,
body: mapping
}).then(() => {
log('debug', `Successfully created a mapping for ${index}`);
return bluebird.resolve(index);
})
.catch(error => {
log('error', error.message);
return bluebird.reject(error);
});
}
/**
* Add settings to an index
*
* @param {string} index
* @param {object} settings
* @returns {Promise}
*/
function putSettings(index, settings) {
return client.indices.putSettings({
index: index,
body: settings
}).then(() => {
log('debug', `Successfully put settings for ${index}`);
return bluebird.resolve(index);
}).catch(error => {
log('error', error.message);
return bluebird.reject(error);
});
}
/**
* Check if an Elasticsearch mapping exists
* Note: An empty mapping can still exist
*
* @param {string} index
* @param {type} type
* @returns {Promise}
*/
function existsMapping(index) {
return client.indices.getMapping({index});
}
/**
* Create an Elasticsearch mapping if one doesn't exist
*
* @param {string} index
* @param {object} mapping
* @returns {Promise}
*/
function createMappingIfNone(index, mapping) {
return module.exports.existsMapping(index)
.then(function (result) {
let getMapping = _.get(result, `${index}.mappings.${FIXED_TYPE}`);
if (!_.size(getMapping)) {
log('warn', `Mapping is missing for ${index}!`);
return module.exports.initMapping(index, mapping)
.then(function (result) {
log('info', `Creating mapping ${index}: ${result}`);
}).catch(error => {
log('error', error.message, { stack: error.stack });
});
} else {
log('debug', `Mapping found for ${index}`);
}
})
.catch(logError);
}
/**
* Create an Elasticsearch index if one doesn't exist
*
* @param {string} index
* @param {string} settings
* @returns {Promise}
*/
function createIndexIfNone(index, settings) {
return existsIndex(index)
.then(function (exists) {
if (!exists) {
return module.exports.initIndex(index, settings)
.then(() => {
log('info', `Creating Elasticsearch index: ${index}`);
})
.catch(error => {
log('error', error);
});
} else {
log('debug', `Elasticsearch index exists at ${index}`);
}
});
}
/**
* Create an Elasticsearch alias if one doesn't exist
*
* @param {String} index
* @param {String} prefix
* @returns {Promise}
*/
function createAliasIfNone(index, prefix) {
const indexWithPrefix = createIndexName(index, prefix),
aliasWithPrefix = `${helpers.indexWithPrefix(index, prefix)}`;
return module.exports.existsAlias(aliasWithPrefix)
.then(function (exists) {
if (!exists) {
// Indices should have a different name than the alias
// Append '_v1' to newly created indices
return module.exports.initAlias(aliasWithPrefix, indexWithPrefix)
.then(function (result) {
log('info', `Creating Elasticsearch alias ${aliasWithPrefix}: ${JSON.stringify(result)}`);
})
.catch(error => {
log('error', error);
});
} else {
log('debug', `Elasticsearch alias exists at ${indexWithPrefix}`);
}
});
}
/**
* Convert Redis batch operations to Elasticsearch batch operations
*
* @param {string} index
* @param {Array} ops
* @returns {Array}
*/
function convertRedisBatchtoElasticBatch(index, ops) {
let bulkOps = [];
_.each(ops, function (op) {
if (_.isString(op.value)) {
let err = new TypeError('op.value cannot be string');
log('error', 'op.value cannot be a string', {
stack: err.stack,
op
});
} else if (op.type === 'put') {
let indexOp = {
_index: index,
_type: FIXED_TYPE
};
// key is optional; if missing, an id will be generated that is unique across all shards
if (op.key) {
indexOp._id = op.key;
}
bulkOps.push({ index: indexOp }, op.value);
} else {
log('warn', `Unhandled batch operation: ${op}`);
}
});
return bulkOps;
}
/**
* Query an Elasticsearch index
*
* @param {string} index
* @param {string} query
* @param {string} type
* @returns {Promise}
*/
function query(index, query) {
return client.search({
index: index,
type: FIXED_TYPE,
body: query
})
.catch(error => {
log('error', error);
return bluebird.reject(error);
});
}
/**
* Index an Elasticsearch document
*
* @param {string} index
* @param {string} ref, e.g. 'localhost.dev.nymag.biz/scienceofus/_components/article/instances/section-test'
* @param {object} source
* @returns {Promise}
*/
function put(index, ref, source) {
return client.index({
index: index,
type: FIXED_TYPE,
id: ref,
body: source
}).then(function (resp) {
log('debug', JSON.stringify(resp));
}).catch(error => {
log('error', error);
return bluebird.reject(error);
});
}
/**
* Delete an Elasticsearch document
*
* @param {string} index
* @param {string} ref, e.g. 'localhost.dev.nymag.biz/scienceofus/_components/article/instances/section-test'
* @returns {Promise}
*/
function del(index, ref) {
return client.delete({
index: index,
type: FIXED_TYPE,
id: ref
}).then(function (resp) {
log('debug', JSON.stringify(resp));
return resp;
}).catch(error => {
log('error', error);
return bluebird.reject(error);
});
}
/**
* Perform multiple index operations
*
* @param {Array} ops
* @returns {Promise}
*/
function batch(ops) {
return client.bulk({
body: ops
}).then(function (resp) {
if (resp && resp.errors === true) {
let err = new Error('Client.bulk errored on batch operation');
log('error', err.message, {
ops,
items: resp.items
});
return bluebird.reject(err);
}
});
}
/**
* Create the ES Client or an empty object
*
* @param {Object} overrideClient
* @returns {Promise}
*/
function setup(overrideClient) {
if (!module.exports.endpoint && !overrideClient) {
let err = new Error('No Elastic endpoint or client override');
log('fatal', `${err.message}`, { stack: err.stack });
return bluebird.reject(err);
}
// Set the exported client
module.exports.client = client = module.exports.endpoint && !overrideClient ? new elastic.Client(_.clone(serverConfig)) : _.assign(module.exports.client, overrideClient);
}
/**
* Check if the correct indices exist, and if they don't, create them.
*
* @param {Object} mappings
* @param {Object} settings
* @param {String} prefix
* @returns {Promise}
*/
function validateIndices(mappings, settings, prefix) {
return bluebird.all(Object.keys(mappings).map(index => {
const aliasWithPrefix = `${helpers.indexWithPrefix(index, prefix)}`,
indexWithPrefix = createIndexName(index, prefix);
return module.exports.existsAlias(aliasWithPrefix)
.then(exists => {
if (!exists) {
// If there's no alias, then it's not pointed to an index, so
// we should create an index to associate
return module.exports.createIndexIfNone(indexWithPrefix, settings[index])
.then(() => module.exports.initAlias(aliasWithPrefix, indexWithPrefix))
.then(result => {
log('info', `Creating Elasticsearch alias ${aliasWithPrefix}: ${JSON.stringify(result)}`);
})
.catch(error => log('error', error));
} else {
log('debug', `Elasticsearch alias exists at ${indexWithPrefix}`);
}
});
}))
.then(() => {
return bluebird.all(_.reduce(mappings, (acc, types, index) => {
// Push the Promise of creating the mapping into the accumulator
acc.push(module.exports.createMappingIfNone(createIndexName(index, prefix), types[FIXED_TYPE]));
return acc;
}, []));
})
.catch(error => log('error', error.message, { stack: error.stack }));
}
/**
* Retrieve the data for a document in the
* index with a matching `id`
*
* @param {string} index
* @param {string} id
* @return {Promise}
*/
function getDocument(index, id) {
if (!id) {
let err = new Error('Cannot get a document without the id');
logError(err);
return bluebird.reject(err);
}
return client.get({
index: index,
type: FIXED_TYPE,
id: id
});
}
/**
* Update a document in an elastic search index.
*
* @param {string} index
* @param {string} id
* @param {object} data
* @param {Boolean} refresh
* @param {Boolean} upsert
* @param {Boolean} retry
* @return {Promise}
*/
function update(index, id, data, refresh = false, upsert = false, retry = 10) { // eslint-disable-line max-params
if (!data) {
let err = new Error('Updating an Elastic document requires a data object');
logError(err);
return bluebird.reject(err);
}
return client.update({
index,
type: FIXED_TYPE,
id,
refresh,
body: {
doc: data,
doc_as_upsert: upsert,
retryOnConflict: retry
}
});
}
/**
* Get all the aliases
*
* @returns {Promise}
*/
function getAliases() {
return client.cat.aliases({
format: 'json',
});
}
/**
* Given an alias, return the index
*
* @param {String} index
* @returns {Promise}
*/
function findIndexFromAlias(index) {
return getAliases()
.then(resp => {
if (!resp.length) {
return `${index}_v1`;
} else {
return resp.filter(item => {
return item.alias.indexOf(index) !== -1;
})[0].index;
}
});
}
module.exports.setup = setup;
module.exports.endpoint = endpoint;
module.exports.healthCheck = healthCheck;
module.exports.initIndex = initIndex;
module.exports.initAlias = initAlias;
module.exports.createIndexIfNone = createIndexIfNone;
module.exports.createAliasIfNone = createAliasIfNone;
module.exports.findIndexFromAlias = findIndexFromAlias;
module.exports.getAliases = getAliases;
module.exports.existsAlias = existsAlias;
module.exports.existsIndex = existsIndex;
module.exports.existsDocument = existsDocument;
module.exports.initMapping = initMapping;
module.exports.existsMapping = existsMapping;
module.exports.putSettings = putSettings;
module.exports.createMappingIfNone = createMappingIfNone;
module.exports.convertRedisBatchtoElasticBatch = convertRedisBatchtoElasticBatch;
module.exports.getDocument = getDocument;
module.exports.update = update;
module.exports.del = del;
module.exports.batch = batch;
module.exports.put = put;
module.exports.query = query;
module.exports.validateIndices = validateIndices;
module.exports.getInstance = () => module.exports.client;
// Exported for testing
module.exports.createIndexName = createIndexName;
module.exports.setLog = mock => log = mock;