@microfleet/core
Version:
Abstract microservice core
319 lines (271 loc) • 10.9 kB
text/typescript
import assert = require('assert')
import Bluebird = require('bluebird')
import Errors = require('common-errors')
import eventToPromise = require('event-to-promise')
import { NotFoundError } from 'common-errors'
import get = require('get-value')
import { ActionTransport, Microfleet, PluginTypes, ValidatorPlugin, PluginInterface } from '../'
import _require from '../utils/require'
import getAMQPRouterAdapter from './amqp/router/adapter'
import { verifyAttachPossibility } from './router/verifyAttachPossibility'
import * as RequestTracker from './router/request-tracker'
/**
* Helpers Section
*/
const NULL_UUID = '00000000-0000-0000-0000-000000000000'
const identity = <T>(arg: T) => arg
/**
* Calculate priority based on message expiration time.
* Logic behind it is to give each expiration a certain priority bucket
* based on the amount of priority levels in the RabbitMQ queue.
* @param expiration - Current expiration (retry) time.
* @param maxExpiration - Max possible expiration (retry) time.
* @returns Queue Priority Level.
*/
function calculatePriority(expiration: number, maxExpiration: number) {
const newExpiration = Math.min(expiration, maxExpiration)
return 100 - Math.floor((newExpiration / maxExpiration) * 100)
}
/**
* Plugin Name
*/
export const name = 'amqp'
/**
* Plugin Type
*/
export const type = PluginTypes.transport
/**
* Relative priority inside the same plugin group type
*/
export const priority = 0
/**
* Attaches plugin to the Mthis class.
* @param {Object} config - AMQP plugin configuration.
*/
export function attach(this: Microfleet & ValidatorPlugin, opts: any = {}): PluginInterface {
assert(this.hasPlugin('logger'), new NotFoundError('log module must be included'))
assert(this.hasPlugin('validator'), new NotFoundError('validator module must be included'))
const config = this.validator.ifError('amqp', opts)
const AMQPTransport = _require('@microfleet/transport-amqp')
// eslint-disable-next-line @typescript-eslint/no-var-requires
const Backoff = require('@microfleet/transport-amqp/lib/utils/recovery')
const ERROR_NOT_STARTED = new Errors.NotPermittedError('amqp was not started')
const ERROR_NOT_HEALTHY = new Errors.ConnectionError('amqp is not healthy')
/**
* Check if the service has an amqp transport.
* @returns A truthy value if the this has an instance of AMQPTransport.
*/
const isStarted = () => (
this.amqp && this.amqp instanceof AMQPTransport
)
const waitForRequestsToFinish = () => {
return RequestTracker.waitForRequestsToFinish(this, ActionTransport.amqp)
}
/**
* Check the state of a connection to the amqp server.
* @param amqp - Instance of AMQPTransport.
* @returns A truthy value if a provided connection is open.
*/
const isConnected = (amqp: typeof AMQPTransport) => (
amqp._amqp && amqp._amqp.state === 'open'
)
// init logger if this is enabled
const logger = this.log.child({ namespace: '@microfleet/transport-amqp' })
// initializes custom onComplete function
if (config.retry && config.retry.enabled === true) {
assert.equal(
config.transport.bindPersistantQueueToHeadersExchange, true,
'config.transport.bindPersistantQueueToHeadersExchange must be set to true'
)
assert.ok(config.retry.queue || config.transport.queue, '`retry.queue` or `transport.queue` must be truthy string')
assert.equal(typeof config.transport.onComplete, 'undefined', 'transport.onComplete must be undefined')
assert.equal(typeof config.transport.neck, 'number', 'neck must be set to >= 0')
assert.ok(config.transport.neck >= 0, 'neck must be set for the retry to work')
assert.equal(typeof config.retry.predicate, 'function', '`retry.predicate` must be defined')
// adds queue setup connector - will be initialized after AMQP is connected
this.retryQueue = config.retry.queue || `x-delay-${config.transport.queue}`
// cache vars for faster access
const { retry } = config
const { predicate, maxRetries } = retry
const backoff = new Backoff({ qos: retry })
const prefix = get(config, 'router.prefix', '')
/**
* Composes onComplete handler for QoS enabled Subscriber.
* Allows one to set custom fast-rejection policy.
* Relies on certain configuration options of the initialized this.
*
* @param err - Possible error.
* @param data - Anything that is a response.
* @param actionName - In-flight action name.
* @param message - An amqp-coffee raw message.
*/
config.transport.onComplete = async (err: any, data: any, actionName: string, message: any) => {
const { properties } = message
const { headers } = properties
// reassign back so that response can be routed properly
if (headers['x-original-correlation-id'] !== undefined) {
properties.correlationId = headers['x-original-correlation-id']
}
if (headers['x-original-reply-to'] !== undefined) {
properties.replyTo = headers['x-original-reply-to']
}
if (!err) {
if (logger) {
logger.info('Sent, ack: [%s]', actionName)
}
message.ack()
return data
}
// check for current try
err.retryAttempt = (headers['x-retry-count'] || 0)
const retryCount = err.retryAttempt + 1
// quite complex, basicaly verifies that these are not logic errors
// and that if there were no other problems - that we haven't exceeded max retries
if (predicate(err, actionName) || retryCount > maxRetries) {
// we must ack, otherwise message would be returned to sender with reject
// instead of promise.reject
message.ack()
if (logger !== undefined) {
const logLevel = err.retryAttempt === 0 ? 'warn' : 'error'
logger[logLevel]({ err, properties }, 'Failed: [%s]', actionName)
}
return Bluebird.reject(err)
}
// assume that predefined accounts must not fail - credentials are correct
if (logger) {
logger.warn({ err, properties }, 'Retry: [%s]', actionName)
}
// retry message options
const expiration = backoff.get('qos', retryCount)
const routingKey = prefix ? `${prefix}.${actionName}` : actionName
const retryMessageOptions: any = {
confirm: true,
expiration: expiration.toString(),
headers: {
'routing-key': routingKey,
'x-original-error': String(err),
'x-retry-count': retryCount,
},
mandatory: true,
priority: calculatePriority(expiration, retry.max),
skipSerialize: true,
}
// deal with special routing properties
const { replyTo, correlationId } = properties
// correlation id is used in routing stuff back from DLX, so we have to "hide" it
// same with replyTo
if (replyTo !== undefined) {
retryMessageOptions.headers['x-original-reply-to'] = replyTo
}
if (correlationId !== undefined) {
retryMessageOptions.headers['x-original-correlation-id'] = correlationId
}
if (this.amqp == null) {
try {
const toWrap = eventToPromise.multi(this as any, ['plugin:connect:amqp'], [
'plugin:close:amqp',
'error',
])
await Bluebird.resolve(toWrap).timeout(10000)
} catch (e) {
message.retry()
return Bluebird.reject(e)
}
}
try {
await this.amqp.send(this.retryQueue, message.raw, retryMessageOptions)
} catch (e) {
if (logger) {
logger.error({ err: e }, 'Failed to queue retried message')
}
message.retry()
return Bluebird.reject(err)
}
if (logger) {
logger.debug('queued retry message')
}
message.ack()
// enrich error
err.scheduledRetry = true
// reset correlation id
// that way response will actually come, but won't be routed in the private router
// of the sender
properties.correlationId = NULL_UUID
// reject with an error, yet a retry will still occur
return Bluebird.reject(err)
}
}
if (config.router && config.router.enabled === true) {
verifyAttachPossibility(this.router, ActionTransport.amqp)
this.AMQPRouter = getAMQPRouterAdapter(this.router, config)
const { prefix } = config.router
// allow ms-amqp-transport to discover routes
config.transport.listen = Object.keys(this.router.routes.amqp)
.map(prefix ? route => `${prefix}.${route}` : identity)
}
return {
/**
* Generic AMQP Connector.
* @returns Opens connection to AMQP.
*/
async connect(this: Microfleet) {
if (this.amqp) {
return Bluebird.reject(new Errors.NotPermittedError('amqp was already started'))
}
// if this.router is present - we will consume messages
// if not - we will only create a client
const connectionOptions = {
...config.transport,
log: logger || null,
tracer: this.tracer,
}
const amqp = this.amqp = await AMQPTransport.connect(connectionOptions, this.AMQPRouter)
// create extra queue for retry logic based on RabbitMQ DLX & headers exchanges
if (config.retry && config.retry.enabled === true) {
// in case defaults were overwritten - throw here
assert.ok(amqp.config.headersExchange.exchange, 'transport.headersExchange.exchange must be set')
await amqp.createQueue({
arguments: {
'x-dead-letter-exchange': amqp.config.headersExchange.exchange,
'x-max-priority': 100, // to support proper priorities
},
autoDelete: false,
durable: true,
queue: this.retryQueue,
router: null,
})
}
this.emit('plugin:connect:amqp', amqp)
return amqp
},
/**
* Health checker.
*
* Returns true if connection state is 'open', otherwise throws an error.
* Connection state depends on actual connection status, but it could be
* modified when a heartbeat message from a message broker is missed during
* a twice heartbeat interval.
* @returns A truthy value if all checks are passed.
*/
async status(this: Microfleet) {
assert(isStarted(), ERROR_NOT_STARTED)
assert(isConnected(this.amqp), ERROR_NOT_HEALTHY)
return true
},
getRequestCount(this: Microfleet) {
return RequestTracker.getRequestCount(this, ActionTransport.amqp)
},
/**
* Generic AMQP disconnector.
* @returns Closes connection to AMQP.
*/
async close(this: Microfleet) {
assert(isStarted(), ERROR_NOT_STARTED)
await this.amqp.closeAllConsumers()
await waitForRequestsToFinish()
await this.amqp.close()
this.amqp = null
this.emit('plugin:close:amqp')
},
}
}