UNPKG

@restorecommerce/chassis-srv

Version:

Restore Commerce microservice chassis

679 lines 29.2 kB
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