@restorecommerce/chassis-srv
Version:
Restore Commerce microservice chassis
679 lines • 29.2 kB
JavaScript
import { isNullish, forEachObj, isEmptyish, keys, pick, clone, isIncludedIn, omitBy, intersection } from 'remeda';
import * as database from './../database/index.js';
import { registerProtoMeta } from '@restorecommerce/kafka-client';
import * as async from 'async';
import { protoMetadata } from '@restorecommerce/rc-grpc-clients/dist/generated-server/io/restorecommerce/commandinterface.js';
import { HealthCheckResponse_ServingStatus } from '@restorecommerce/rc-grpc-clients/dist/generated-server/grpc/health/v1/health.js';
// For some reason this is required
import { randomBytes } from 'crypto';
registerProtoMeta(protoMetadata);
/**
* Base implementation.
* Currently includes:
* * 'check' - returns UNKNOWN, SERVING or NOT_SERVING
* * 'version' - returns NPM service version and Node.js version
* * 'reset' - truncated all DB instances specified in config files
* * 'restore' - re-reads Kafka events to restore a set of ArangoDB collections' data
* Unimplemented:
* * reconfigure
*
* In case of custom data/events handling or service-specific operations regarding
* a certain method, such method should be extended or overriden.
*/
export class CommandInterface {
logger;
config;
health;
service;
kafkaEvents;
commands;
commandTopic;
bufferedCollection;
redisClient;
constructor(server, config, logger, events, redisClient) {
if (isNullish(events)) {
if (logger.error) {
logger.error('No Kafka client was provided. Disabling all commands.');
return;
}
}
this.config = config;
this.logger = logger;
this.redisClient = redisClient;
if (!this.config.get('events:kafka:topics:command')) {
throw new Error('Commands topic configuration was not provided.');
}
this.kafkaEvents = events;
// Health
this.health = {
status: HealthCheckResponse_ServingStatus.UNKNOWN,
};
this.service = {};
server.on('bound', (serviceName) => {
this.service[serviceName] = {
bound: true,
transport: {},
};
this.health.status = HealthCheckResponse_ServingStatus.NOT_SERVING;
});
server.on('serving', (transports) => {
this.health.status = HealthCheckResponse_ServingStatus.SERVING;
forEachObj(transports, (transport, transportName) => {
forEachObj(this.service, (srv, serviceName) => {
this.service[serviceName].transport[transportName] = HealthCheckResponse_ServingStatus.SERVING;
});
});
});
server.on('stopped', (transports) => {
this.health.status = HealthCheckResponse_ServingStatus.NOT_SERVING;
forEachObj(transports, (transport, transportName) => {
forEachObj(this.service, (srv, serviceName) => {
this.service[serviceName].transport[transportName] = HealthCheckResponse_ServingStatus.NOT_SERVING;
});
});
});
// list of available commands
this.commands = {
reset: this.reset,
restore: this.restore,
reconfigure: this.reconfigure,
health_check: this.check,
version: this.version,
config_update: this.configUpdate,
set_api_key: this.setApiKey,
flush_cache: this.flushCache
};
const topicCfg = config.get('events:kafka:topics:command');
events.topic(topicCfg.topic).then(topic => this.commandTopic = topic).catch(err => {
this.logger.error('Error occurred while retrieving command kafka topic', {
code: err.code,
message: err.message,
stack: err.stack
});
});
// check for buffer fields
const buffFields = this.config.get('fieldHandlers:bufferFields') ?? {};
this.bufferedCollection = new Map(Object.entries(buffFields));
this.logger.info('Buffered collections are:', this.bufferedCollection);
}
/**
* Generic command operation, which demultiplexes a command by its name and parameters.
*/
async command(request, context) {
if (isNullish(request) || isNullish(request.name)) {
return this.encodeMsg({
error: {
code: 400,
message: 'No command name provided',
}
});
}
if (isNullish(this.commands[request.name])) {
return this.encodeMsg({
error: {
code: 400,
message: `Command name ${request.name} does not exist`
}
});
}
const payload = request.payload ? this.decodeMsg(request.payload) : null;
// calling operation bound to the command name
const result = await Promise.resolve(this.commands[request.name].apply(this, [payload])).catch(err => ({
error: {
code: 404,
message: err.message
}
}));
return this.encodeMsg(result);
}
/**
* Reconfigure service
* @param call
* @param context
*/
reconfigure() {
this.logger.info('reconfigure is not implemented');
return {
error: {
code: 501,
message: 'reconfigure is not implemented',
}
};
}
/**
* Restore the system by re-reading Kafka messages.
* This base implementation restores documents from a set of
* ArangoDB database collections, using the chassis-srv database provider.
* @param topics list of Kafka topics to be restored
*/
async restore(payload) {
if (isEmptyish(payload) || isEmptyish(payload.data)) {
// throw new errors.InvalidArgument('Invalid payload for restore command');
return {
error: {
code: 400,
message: 'Invalid payload for restore command'
}
};
}
const restoreData = payload.data || [];
// the Kafka config should contains a key-value pair, mapping
// a label with the topic's name
const kafkaEventsCfg = this.config.get('events:kafka');
const kafkaCfg = this.config.get('events:kafka:topics');
if (isNullish(kafkaCfg) || kafkaCfg.length == 0) {
return {
error: {
code: 500,
message: 'Kafka topics config not available'
}
};
}
const topicLabels = keys(kafkaCfg).filter((elem, index) => {
return elem.includes('.resource');
}).map((elem) => {
return elem.replace('.resource', '');
});
const restoreSetup = {};
const restoreEventSetup = {};
restoreData.forEach((data) => {
const ignoreOffset = (data.ignore_offset || []).filter((offset) => {
const isNumber = Number(offset);
if (!isNumber) {
this.logger.warn(`Invalid value for "ignore_offset" parameter in restore: ${offset}`);
}
return isNumber;
});
restoreSetup[data.entity] = {
baseOffset: Number(data.base_offset) || 0,
ignoreOffset
};
});
const restoreCollections = keys(restoreSetup);
try {
const dbCfgs = this.config.get('database');
const dbCfgNames = keys(dbCfgs);
for (let i = 0; i < dbCfgNames.length; i += 1) {
const dbCfgName = dbCfgNames[i];
const dbCfg = dbCfgs[dbCfgName];
const collections = dbCfg.collections;
let graphName, edgeConfigDefs;
if (this.config.get('graph')) {
graphName = this.config.get('graph:graphName');
edgeConfigDefs = this.config.get('graph:edgeDefinitions');
}
const db = await database.get(dbCfg, this.logger, graphName, edgeConfigDefs);
if (isNullish(collections)) {
this.logger.warn('No collections found on DB config');
return {};
}
let inter = intersection(restoreCollections, collections);
if (inter.length > 0) {
inter = intersection(inter, topicLabels);
for (const resource of inter) {
const topicName = kafkaCfg[`${resource}.resource`].topic;
restoreEventSetup[topicName] = {
topic: await this.kafkaEvents.topic(topicName),
events: this.makeResourcesRestoreSetup(db, resource),
baseOffset: restoreSetup[resource].baseOffset,
ignoreOffset: restoreSetup[resource].ignoreOffset
};
}
}
}
if (isEmptyish(restoreEventSetup)) {
this.logger.warn('No data was setup for the restore process.');
}
else {
const logger = this.logger;
const kafkaEvents = this.kafkaEvents;
const config = this.config;
const service = this.service;
const encodeMsg = this.encodeMsg.bind(this);
const commandTopic = this.commandTopic;
const startToReceiveRestoreMessages = this.startToReceiveRestoreMessages.bind(this);
// Start the restore process
this.logger.warn('restoring data');
for (const topicName in restoreEventSetup) {
const topicSetup = restoreEventSetup[topicName];
const restoreTopic = topicSetup.topic;
const topicEvents = topicSetup.events;
// saving listeners for potentially subscribed events on this topic,
// so they don't get called during the restore process
const previousEvents = clone(restoreTopic.subscribed);
const listenersBackup = new Map();
for (const event of previousEvents) {
listenersBackup.set(event, restoreTopic.emitter.listeners(event));
await restoreTopic.removeAllListeners(event);
}
// const eventNames = _.keys(restoreTopic.events);
const baseOffset = topicSetup.baseOffset;
const targetOffset = (await restoreTopic.$offset(BigInt(-1))) - BigInt(1);
const ignoreOffsets = topicSetup.ignoreOffset;
const eventNames = keys(topicEvents);
this.logger.debug(`topic ${topicName} has current offset ${targetOffset}`);
const restoreGroupId = kafkaEventsCfg.groupId + '-restore-' + randomBytes(32).toString('hex');
const consumer = await this.kafkaEvents.provider.newConsumer(restoreGroupId);
let messageStream;
const drainEvent = (message, done) => {
const msg = message.value;
const eventName = message.key.toString();
const context = pick(message, ['offset', 'partition', 'topic']);
const eventListener = topicEvents[message.key];
// decode protobuf
let decodedMsg = kafkaEvents.provider.decodeObject(kafkaEventsCfg, eventName, msg);
decodedMsg = pick(decodedMsg, keys(decodedMsg)); // preventing protobuf.js special fields
eventListener(decodedMsg, context, config.get(), eventName).then(() => {
done();
}).catch((err) => {
logger.error(`Exception caught invoking restore listener for event ${eventName}`, { code: err.code, message: err.message, stack: err.stack });
done(err);
});
if (message.offset >= targetOffset) {
for (const event of eventNames) {
restoreTopic.removeAllListeners(event).catch((err) => {
logger.error('Error removing listeners after restore', { code: err.code, message: err.message, stack: err.stack });
});
}
for (const event of previousEvents) {
const listeners = listenersBackup.get(event);
for (const listener of listeners) {
restoreTopic.on(event, listener).catch((err) => {
logger.error('Error subscribing to listeners after restore', { code: err.code, message: err.message, stack: err.stack });
});
}
}
messageStream.close().then(() => {
return consumer.close(true);
}).then(() => {
this.kafkaEvents.provider.admin.deleteGroups({
groups: [restoreGroupId]
}).then(() => {
logger.debug('restore kafka group deleted');
const msg = {
topic: topicName,
offset: Number(message.offset)
};
commandTopic.emit('restoreResponse', {
services: keys(service),
payload: encodeMsg(msg)
}).then(() => {
logger.info('Restore response emitted');
}).catch((err) => {
logger.error('Error emitting command response', { code: err.code, message: err.message, stack: err.stack });
});
logger.info('restore process done');
}).catch((err) => {
logger.error('Error deleting restore kafka group', { code: err.code, message: err.message, stack: err.stack });
});
}).catch(err => {
logger.error('Error stopping consumer', { code: err.code, message: err.message, stack: err.stack });
});
}
};
const asyncQueue = startToReceiveRestoreMessages(restoreTopic, drainEvent);
await consumer.connectToBrokers().then(() => {
logger.info(`Consumer for topic '${topicName}' connected`);
}).catch((err) => {
logger.error('error connecting consumer', { code: err.code, message: err.message, stack: err.stack });
});
consumer.consume({
sessionTimeout: 10000,
heartbeatInterval: 500,
topics: [topicName],
mode: 'manual',
offsets: [{
topic: topicName,
partition: 0,
offset: BigInt(baseOffset)
}],
}).then(stream => {
logger.info(`Consumer for topic '${topicName}' subscribed`);
messageStream = stream;
stream.on('data', (message) => {
if (message.key.toString() in topicEvents && !isIncludedIn(Number(message.offset), ignoreOffsets)) {
asyncQueue.push(message);
logger.debug(`received message ${message.offset}/${targetOffset}`);
}
});
}).catch((err) => {
logger.error(`Consumer for topic '${topicName}' failed to run`, { code: err.code, message: err.message, stack: err.stack });
throw err;
});
}
this.logger.debug('waiting until all messages are processed');
}
}
catch (err) {
this.logger.error('Error occurred while restoring the system', { code: err.code, message: err.message, stack: err.stack });
await this.commandTopic.emit('restoreResponse', {
services: keys(this.service),
payload: this.encodeMsg({
error: err.message
})
});
}
return {};
}
startToReceiveRestoreMessages(restoreTopic, drainEvent) {
const asyncQueue = async.queue((msg, done) => {
setImmediate(() => drainEvent(msg, (err) => {
if (err) {
done(err);
}
else {
done();
}
}));
}, 1);
asyncQueue.drain(() => {
// commit state first, before resuming
this.logger.verbose('Committing offsets upon async queue drain');
restoreTopic.commitCurrentOffsets().then(() => {
this.logger.info('Offset committed successfully');
});
});
this.logger.info('Async queue draining started.');
return asyncQueue;
}
/**
* Reset system data related to a service. Default implementation truncates
* a set of ArangoDB instances, using the chassis-srv database provider.
*/
async reset() {
this.logger.info('reset process started');
if (this.health.status !== HealthCheckResponse_ServingStatus.NOT_SERVING) {
this.logger.warn('reset process starting while server is serving');
}
let errorMsg = null;
try {
const dbCfgs = this.config.get('database');
const dbCfgNames = keys(dbCfgs);
for (let i = 0; i < dbCfgNames.length; i += 1) {
const dbCfgName = dbCfgNames[i];
const dbCfg = dbCfgs[dbCfgName];
const db = await database.get(dbCfg, this.logger);
switch (dbCfg.provider) {
case 'arango':
await db.truncate();
this.logger.info(`arangodb ${dbCfg.database} truncated`);
break;
default:
this.logger.error(`unsupported database provider ${dbCfg.provider} in database config ${dbCfgName}`);
break;
}
}
}
catch (err) {
this.logger.error('Unexpected error while resetting the system', { code: err.code, message: err.message, stack: err.stack });
errorMsg = err.message;
}
const eventObject = {
services: keys(this.service),
payload: null
};
if (errorMsg) {
eventObject.payload = this.encodeMsg({
error: errorMsg
});
}
else {
eventObject.payload = this.encodeMsg({
status: 'Reset concluded successfully'
});
}
await this.commandTopic.emit('resetResponse', eventObject);
this.logger.info('reset process ended');
if (errorMsg) {
return {
error: errorMsg
};
}
return {
status: 'Reset concluded successfully'
};
}
/**
* Check the service status
*/
async check(payload) {
if (isNullish(payload)) {
throw new Error('Invalid payload for restore command');
}
const serviceName = payload.service;
if (isNullish(serviceName) || serviceName?.length === 0) {
return {
status: this.health.status,
};
}
const service = this.service[serviceName];
if (isNullish(service)) {
const errorMsg = 'Service ' + serviceName + ' does not exist';
this.logger.warn(errorMsg);
throw new Error(errorMsg);
}
let status = HealthCheckResponse_ServingStatus.UNKNOWN;
// If one transports serves the service, set it to SERVING
forEachObj(service.transport, (transportStatus) => {
if (transportStatus === HealthCheckResponse_ServingStatus.SERVING) {
status = transportStatus;
}
});
return {
status,
};
}
/**
* Retrieve current NPM package and Node version of service
*/
async version() {
const response = {
nodejs: process.version,
version: process.env.npm_package_version,
};
await this.commandTopic.emit('versionResponse', {
services: keys(this.service),
payload: this.encodeMsg(response)
});
return response;
}
/**
* Update config for acs-client to disable it
* @param payload JSON object containing key value pairs for configuration
*/
async configUpdate(payload) {
if (isNullish(payload)) {
return {
error: {
code: 400,
message: 'Invalid payload for configUpdate command'
}
};
}
let response;
try {
const configProperties = Object.keys(payload);
for (const key of configProperties) {
this.config.set(key, payload[key]);
}
response = {
status: 'Configuration updated successfully'
};
await this.commandTopic.emit('configUpdateResponse', {
services: keys(this.service),
payload: this.encodeMsg(response)
});
}
catch (error) {
this.logger.error('Error executing configUpdate Command', { code: error.code, message: error.message, stack: error.stack });
response = error.message;
}
return response;
}
/**
* Sets provided authentication apiKey on configuration
* @param payload JSON object containing key value pairs for authentication apiKey
*/
async setApiKey(payload) {
if (isNullish(payload)) {
return {
error: {
code: 400,
message: 'Invalid payload for setApiKey command'
}
};
}
let response;
try {
const configProperties = Object.keys(payload);
for (const key of configProperties) {
this.config.set(key, payload[key]);
}
response = {
status: 'ApiKey set successfully'
};
await this.commandTopic.emit('setApiKeyResponse', {
services: keys(this.service),
payload: this.encodeMsg(response)
});
}
catch (err) {
this.logger.error('Error executing setApiKey Command', { code: err.code, message: err.message, stack: err.stack });
response = err.message;
}
return response;
}
/**
* Flush the cache based on DB index and prefix passed, if no dbIndex is passed
* then the complete Cache is flushed.
*
* @param prefix An optional prefix to flush instead of entire cache
*/
async flushCache(payload) {
let flushCachePayload;
if (payload && payload.data) {
flushCachePayload = payload.data;
}
let dbIndex, pattern, response;
if (flushCachePayload) {
dbIndex = flushCachePayload.db_index;
pattern = flushCachePayload.pattern;
}
if (dbIndex === undefined || !dbIndex) {
dbIndex = 0;
}
// select the particular dbIndex
await this.redisClient.select(dbIndex);
try {
if (pattern != undefined) {
const flushPattern = '*' + pattern + '*';
this.logger.debug('Flushing cache wiht pattern', { dbIndex, flushPattern });
let scanIterator;
try {
scanIterator = this.redisClient.scanIterator({ MATCH: flushPattern, COUNT: 100 });
for await (const key of scanIterator) {
await this.redisClient.del(key);
}
this.logger.debug(`Successfully flushed cache pattern ${flushPattern}`);
response = {
status: 'Successfully flushed cache pattern'
};
}
catch (err) {
this.logger.error('Error creating stream / pipeline in Redis', { code: err.code, message: err.message, stack: err.stack });
response = err.message;
}
}
else {
this.logger.debug('Flushing cache', { dbIndex });
if (dbIndex || dbIndex === 0) {
// Flush all keys in the given dbIndex (flushDB)
await this.redisClient.flushDb();
response = {
status: `Successfully flushed cache with DB index ${dbIndex}`
};
this.logger.debug('Successfully flushed cache with DB index', { dbIndex });
}
else {
// Flush Complete Redis Cache (flushAll)
await this.redisClient.flushAll();
response = {
status: 'Successfully flushed complete cache'
};
this.logger.debug('Successfully flushed complete cache');
}
}
}
catch (err) {
this.logger.error('Error flushing Redis Cache', { code: err.code, message: err.message, stack: err.stack });
response = err.message;
}
await this.commandTopic.emit('flushCacheResponse', {
services: keys(this.service),
payload: this.encodeMsg(response)
});
return response;
}
// Helper functions
/**
* Generic resource restore setup.
* @param db
* @param resource
*/
makeResourcesRestoreSetup(db, resource) {
const decodeBufferField = this.decodeBufferField.bind(this);
return {
[`${resource}Created`]: async function restoreCreated(message, ctx, config, eventName) {
decodeBufferField(message, resource);
await db.insert(`${resource}s`, message);
return {};
},
[`${resource}Modified`]: async function restoreModified(message, ctx, config, eventName) {
decodeBufferField(message, resource);
await db.update(`${resource}s`, { id: message.id }, omitBy(message, isNullish));
return {};
},
[`${resource}Deleted`]: async function restoreDeleted(message, ctx, config, eventName) {
await db.delete(`${resource}s`, { id: message.id });
return {};
}
};
}
/**
* Check if the message contains buffered field, if so decode it.
* @param message
* @param collectionName
*/
decodeBufferField(message, resource) {
if (this.bufferedCollection.has(resource)) {
const bufferField = this.bufferedCollection.get(resource);
// check if received message contains buffered data, if so
// decode the bufferField and store in DB
if (message[bufferField] && message[bufferField].value) {
message[bufferField] = JSON.parse(message[bufferField].value.toString());
}
}
}
/**
*
* @param msg google.protobuf.Any
* @returns Arbitrary JSON
*/
decodeMsg(msg) {
return JSON.parse(Buffer.from(msg.value).toString());
}
/**
*
* @param msg Arbitrary JSON
* @returns google.protobuf.Any formatted message
*/
encodeMsg(msg) {
if (msg) {
return {
type_url: 'payload',
value: Buffer.from(JSON.stringify(msg))
};
}
}
}
//# sourceMappingURL=index.js.map