UNPKG

kafkajs

Version:

A modern Apache Kafka client for node.js

350 lines (301 loc) 9.92 kB
const Broker = require('../broker') const createRetry = require('../retry') const shuffle = require('../utils/shuffle') const arrayDiff = require('../utils/arrayDiff') const { KafkaJSBrokerNotFound, KafkaJSProtocolError } = require('../errors') const { keys, assign, values } = Object const hasBrokerBeenReplaced = (broker, { host, port, rack }) => broker.connectionPool.host !== host || broker.connectionPool.port !== port || broker.connectionPool.rack !== rack module.exports = class BrokerPool { /** * @param {object} options * @param {import("./connectionPoolBuilder").ConnectionPoolBuilder} options.connectionPoolBuilder * @param {import("../../types").Logger} options.logger * @param {import("../../types").RetryOptions} [options.retry] * @param {boolean} [options.allowAutoTopicCreation] * @param {number} [options.authenticationTimeout] * @param {number} [options.metadataMaxAge] */ constructor({ connectionPoolBuilder, logger, retry, allowAutoTopicCreation, authenticationTimeout, metadataMaxAge, }) { this.rootLogger = logger this.connectionPoolBuilder = connectionPoolBuilder this.metadataMaxAge = metadataMaxAge || 0 this.logger = logger.namespace('BrokerPool') this.retrier = createRetry(assign({}, retry)) this.createBroker = options => new Broker({ allowAutoTopicCreation, authenticationTimeout, ...options, }) this.brokers = {} /** @type {Broker | undefined} */ this.seedBroker = undefined /** @type {import("../../types").BrokerMetadata | null} */ this.metadata = null this.metadataExpireAt = null this.versions = null } /** * @public * @returns {Boolean} */ hasConnectedBrokers() { const brokers = values(this.brokers) return ( !!brokers.find(broker => broker.isConnected()) || (this.seedBroker ? this.seedBroker.isConnected() : false) ) } async createSeedBroker() { if (this.seedBroker) { await this.seedBroker.disconnect() } const connectionPool = await this.connectionPoolBuilder.build() this.seedBroker = this.createBroker({ connectionPool, logger: this.rootLogger, }) } /** * @public * @returns {Promise<void>} */ async connect() { if (this.hasConnectedBrokers()) { return } if (!this.seedBroker) { await this.createSeedBroker() } return this.retrier(async (bail, retryCount, retryTime) => { try { await this.seedBroker.connect() this.versions = this.seedBroker.versions } catch (e) { if (e.name === 'KafkaJSConnectionError' || e.type === 'ILLEGAL_SASL_STATE') { // Connection builder will always rotate the seed broker await this.createSeedBroker() this.logger.error( `Failed to connect to seed broker, trying another broker from the list: ${e.message}`, { retryCount, retryTime } ) } else { this.logger.error(e.message, { retryCount, retryTime }) } if (e.retriable) throw e bail(e) } }) } /** * @public * @returns {Promise} */ async disconnect() { this.seedBroker && (await this.seedBroker.disconnect()) await Promise.all(values(this.brokers).map(broker => broker.disconnect())) this.brokers = {} this.metadata = null this.versions = null } /** * @public * @param {Object} destination * @param {string} destination.host * @param {number} destination.port */ removeBroker({ host, port }) { const removedBroker = values(this.brokers).find( broker => broker.connectionPool.host === host && broker.connectionPool.port === port ) if (removedBroker) { delete this.brokers[removedBroker.nodeId] this.metadataExpireAt = null if (this.seedBroker.nodeId === removedBroker.nodeId) { this.seedBroker = shuffle(values(this.brokers))[0] } } } /** * @public * @param {Array<String>} topics * @returns {Promise<null>} */ async refreshMetadata(topics) { const broker = await this.findConnectedBroker() const { host: seedHost, port: seedPort } = this.seedBroker.connectionPool return this.retrier(async (bail, retryCount, retryTime) => { try { this.metadata = await broker.metadata(topics) this.metadataExpireAt = Date.now() + this.metadataMaxAge const replacedBrokers = [] this.brokers = await this.metadata.brokers.reduce( async (resultPromise, { nodeId, host, port, rack }) => { const result = await resultPromise if (result[nodeId]) { if (!hasBrokerBeenReplaced(result[nodeId], { host, port, rack })) { return result } replacedBrokers.push(result[nodeId]) } if (host === seedHost && port === seedPort) { this.seedBroker.nodeId = nodeId this.seedBroker.connectionPool.rack = rack return assign(result, { [nodeId]: this.seedBroker, }) } return assign(result, { [nodeId]: this.createBroker({ logger: this.rootLogger, versions: this.versions, connectionPool: await this.connectionPoolBuilder.build({ host, port, rack }), nodeId, }), }) }, this.brokers ) const freshBrokerIds = this.metadata.brokers.map(({ nodeId }) => `${nodeId}`).sort() const currentBrokerIds = keys(this.brokers).sort() const unusedBrokerIds = arrayDiff(currentBrokerIds, freshBrokerIds) const brokerDisconnects = unusedBrokerIds.map(nodeId => { const broker = this.brokers[nodeId] return broker.disconnect().then(() => { delete this.brokers[nodeId] }) }) const replacedBrokersDisconnects = replacedBrokers.map(broker => broker.disconnect()) await Promise.all([...brokerDisconnects, ...replacedBrokersDisconnects]) } catch (e) { if (e.type === 'LEADER_NOT_AVAILABLE') { throw e } bail(e) } }) } /** * Only refreshes metadata if the data is stale according to the `metadataMaxAge` param or does not contain information about the provided topics * * @public * @param {Array<String>} topics * @returns {Promise<null>} */ async refreshMetadataIfNecessary(topics) { const shouldRefresh = this.metadata == null || this.metadataExpireAt == null || Date.now() > this.metadataExpireAt || !topics.every(topic => this.metadata.topicMetadata.some(topicMetadata => topicMetadata.topic === topic) ) if (shouldRefresh) { return this.refreshMetadata(topics) } } /** @type {() => string[]} */ getNodeIds() { return keys(this.brokers) } /** * @public * @param {object} options * @param {string} options.nodeId * @returns {Promise<Broker>} */ async findBroker({ nodeId }) { const broker = this.brokers[nodeId] if (!broker) { throw new KafkaJSBrokerNotFound(`Broker ${nodeId} not found in the cached metadata`) } await this.connectBroker(broker) return broker } /** * @public * @param {(params: { nodeId: string, broker: Broker }) => Promise<T>} callback * @returns {Promise<T>} * @template T */ async withBroker(callback) { const brokers = shuffle(keys(this.brokers)) if (brokers.length === 0) { throw new KafkaJSBrokerNotFound('No brokers in the broker pool') } for (const nodeId of brokers) { const broker = await this.findBroker({ nodeId }) try { return await callback({ nodeId, broker }) } catch (e) {} } return null } /** * @public * @returns {Promise<Broker>} */ async findConnectedBroker() { const nodeIds = shuffle(keys(this.brokers)) const connectedBrokerId = nodeIds.find(nodeId => this.brokers[nodeId].isConnected()) if (connectedBrokerId) { return await this.findBroker({ nodeId: connectedBrokerId }) } // Cycle through the nodes until one connects for (const nodeId of nodeIds) { try { return await this.findBroker({ nodeId }) } catch (e) {} } // Failed to connect to all known brokers, metadata might be old await this.connect() return this.seedBroker } /** * @private * @param {Broker} broker * @returns {Promise<null>} */ async connectBroker(broker) { if (broker.isConnected()) { return } return this.retrier(async (bail, retryCount, retryTime) => { try { await broker.connect() } catch (e) { if (e.name === 'KafkaJSConnectionError' || e.type === 'ILLEGAL_SASL_STATE') { await broker.disconnect() } // To avoid reconnecting to an unavailable host, we bail on connection errors // and refresh metadata on a higher level before reconnecting if (e.name === 'KafkaJSConnectionError') { return bail(e) } if (e.type === 'ILLEGAL_SASL_STATE') { // Rebuild the connection pool since it can't recover from illegal SASL state broker.connectionPool = await this.connectionPoolBuilder.build({ host: broker.connectionPool.host, port: broker.connectionPool.port, rack: broker.connectionPool.rack, }) this.logger.error(`Failed to connect to broker, reconnecting`, { retryCount, retryTime }) throw new KafkaJSProtocolError(e, { retriable: true }) } if (e.retriable) throw e this.logger.error(e, { retryCount, retryTime, stack: e.stack }) bail(e) } }) } }