kafkajs
Version:
A modern Apache Kafka client for node.js
350 lines (301 loc) • 9.92 kB
JavaScript
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)
}
})
}
}