UNPKG

kafka-node

Version:

Client for Apache Kafka v0.9.x, v0.10.x and v0.11.x

919 lines (796 loc) 26.6 kB
'use strict'; const logger = require('./logging')('kafka-node:ConsumerGroup'); const util = require('util'); const EventEmitter = require('events'); const KafkaClient = require('./kafkaClient'); const Offset = require('./offset'); const _ = require('lodash'); const async = require('async'); const validateConfig = require('./utils').validateConfig; const ConsumerGroupRecovery = require('./consumerGroupRecovery'); const Heartbeat = require('./consumerGroupHeartbeat'); const createTopicPartitionList = require('./utils').createTopicPartitionList; const errors = require('./errors'); const NestedError = require('nested-error-stacks'); const assert = require('assert'); const builtInProtocols = require('./assignment'); const LATEST_OFFSET = -1; const EARLIEST_OFFSET = -2; const ACCEPTED_FROM_OFFSET = { latest: LATEST_OFFSET, earliest: EARLIEST_OFFSET, none: false }; const DEFAULTS = { groupId: 'kafka-node-group', // Auto commit config autoCommit: true, autoCommitIntervalMs: 5000, // Fetch message config fetchMaxWaitMs: 100, paused: false, maxNumSegments: 1000, fetchMinBytes: 1, fetchMaxBytes: 1024 * 1024, maxTickMessages: 1000, fromOffset: 'latest', outOfRangeOffset: 'earliest', sessionTimeout: 30000, retries: 10, retryFactor: 1.8, retryMinTimeout: 1000, commitOffsetsOnFirstJoin: true, connectOnReady: true, migrateHLC: false, onRebalance: null, topicPartitionCheckInterval: 30000, protocol: ['roundrobin'], encoding: 'utf8' }; function ConsumerGroup (memberOptions, topics) { EventEmitter.call(this); const self = this; this.options = _.defaults(memberOptions || {}, DEFAULTS); if (!this.options.heartbeatInterval) { this.options.heartbeatInterval = Math.floor(this.options.sessionTimeout / 3); } if (memberOptions.ssl === true) { memberOptions.ssl = {}; } if (!(this.options.fromOffset in ACCEPTED_FROM_OFFSET)) { throw new Error( `fromOffset ${this.options.fromOffset} should be either: ${Object.keys(ACCEPTED_FROM_OFFSET).join(', ')}` ); } if (!(this.options.outOfRangeOffset in ACCEPTED_FROM_OFFSET)) { throw new Error( `outOfRangeOffset ${this.options.outOfRangeOffset} should be either: ${Object.keys(ACCEPTED_FROM_OFFSET).join( ', ' )}` ); } memberOptions.clientId = memberOptions.id; this.client = new KafkaClient(memberOptions); if (_.isString(topics)) { topics = [topics]; } assert(Array.isArray(topics), 'Array of topics is required'); assert(topics.length, 'Array of topics shall not be empty'); this.topics = topics; this.recovery = new ConsumerGroupRecovery(this); this.setupProtocols(this.options.protocol); if (this.options.connectOnReady && !this.options.migrateHLC) { this.client.once('ready', this.connect.bind(this)); } if (this.options.migrateHLC) { throw new Error( 'This version of KafkaClient cannot be used to migrate from Zookeeper use older version of kafka-node instead' ); } this.client.on('error', function (err) { logger.error('Error from %s', self.client.clientId, err); self.emit('error', err); }); const recoverFromBrokerChange = _.debounce(function () { logger.debug('brokersChanged refreshing metadata'); self.client.refreshMetadata(self.topics, function (error) { if (error) { self.emit('error', error); return; } self.reconnectIfNeeded(); }); }, 200); this.client.on('brokersChanged', function () { self.pause(); recoverFromBrokerChange(); }); this.client.on('reconnect', function () { setImmediate(function () { self.reconnectIfNeeded(); }); }); this.on('offsetOutOfRange', topic => { this.pause(); if (this.options.outOfRangeOffset === 'none') { this.emit( 'error', new errors.InvalidConsumerOffsetError( `Offset out of range for topic "${topic.topic}" partition ${topic.partition}` ) ); return; } topic.time = ACCEPTED_FROM_OFFSET[this.options.outOfRangeOffset]; this.getOffset().fetch([topic], (error, result) => { if (error) { this.emit( 'error', new errors.InvalidConsumerOffsetError(`Fetching ${this.options.outOfRangeOffset} offset failed`, error) ); return; } const offset = _.head(result[topic.topic][topic.partition]); const oldOffset = _.find(this.topicPayloads, { topic: topic.topic, partition: topic.partition }).offset; logger.debug('replacing %s-%s stale offset of %d with %d', topic.topic, topic.partition, oldOffset, offset); this.setOffset(topic.topic, topic.partition, offset); this.resume(); }); }); this._pendingFetches = 0; // 'processingfetch' emits before we start processing new messages // 'done' will be emit when all messages are done emitting this.on('processingfetch', () => this._onFetchProcessing()); this.on('done', topics => this._onFetchDone(topics)); if (this.options.groupId) { validateConfig('options.groupId', this.options.groupId); } this.isLeader = false; this.coordinatorId = null; this.generationId = null; this.ready = false; this.topicPayloads = []; this.payloadMap = {}; } util.inherits(ConsumerGroup, EventEmitter); ConsumerGroup.prototype.reconnectIfNeeded = function () { logger.debug('trying to reconnect if needed'); this.paused = false; if (!this.ready && !this.connecting) { if (this.reconnectTimer) { // brokers changed so bypass backoff retry and reconnect now clearTimeout(this.reconnectTimer); this.reconnectTimer = null; } this.connect(); } else if (!this.connecting) { this.fetch(); } }; ConsumerGroup.prototype.setupProtocols = function (protocols) { if (!Array.isArray(protocols)) { protocols = [protocols]; } this.protocols = protocols.map(function (protocol) { if (typeof protocol === 'string') { if (!(protocol in builtInProtocols)) { throw new Error('Unknown built in assignment protocol ' + protocol); } protocol = _.assign({}, builtInProtocols[protocol]); } else { checkProtocol(protocol); } protocol.subscription = this.topics; return protocol; }, this); }; function checkProtocol (protocol) { assert(protocol, 'protocol is null'); assert(protocol.assign, 'assign function is not defined in the protocol'); assert(protocol.name, 'name must be given to protocol'); assert(protocol.version >= 0, 'version must be >= 0'); } ConsumerGroup.prototype.setCoordinatorId = function (coordinatorId) { this.client.coordinatorId = String(coordinatorId); }; ConsumerGroup.prototype.assignPartitions = function (protocol, groupMembers, callback) { logger.debug('Assigning Partitions to members', groupMembers); logger.debug('Using group protocol', protocol); protocol = _.find(this.protocols, { name: protocol }); if (!protocol) { callback(new Error('Unknown group protocol: ' + protocol)); return; } var self = this; var topics = _(groupMembers) .map('subscription') .flatten() .uniq() .value(); async.waterfall( [ function (callback) { logger.debug('loadingMetadata for topics:', topics); self.client.loadMetadataForTopics(topics, callback); }, function (metadataResponse, callback) { var metadata = mapTopicToPartitions(metadataResponse[1].metadata); self.topicPartitionLength = createTopicPartitionLength(metadata, _.difference(topics, Object.keys(metadata))); logger.debug('mapTopicToPartitions', metadata); protocol.assign(metadata, groupMembers, callback); } ], callback ); }; function createTopicPartitionLength (metadata, emptyTopics) { const topicPartitionLength = {}; _.forOwn(metadata, function (value, key) { topicPartitionLength[key] = value.length; }); for (const topic of emptyTopics) { if (topic in topicPartitionLength) { throw new Error(`Topic ${topic} is not empty`); } topicPartitionLength[topic] = 0; } return topicPartitionLength; } ConsumerGroup.prototype.scheduleTopicPartitionCheck = function () { if (this.isLeader && !this.topicPartitionCheckTimer) { logger.debug(`${this.client.clientId} is leader scheduled new topic/partition check`); this.topicPartitionCheckTimer = setTimeout(() => { this.topicPartitionCheckTimer = null; logger.debug('checking for new topics and partitions'); this._checkTopicPartitionChange((error, changed) => { if (error) { return this.emit('error', new NestedError('topic/partition change check failed', error)); } if (changed) { logger.debug('Topic/Partitions has changed'); async.series([ callback => this.options.autoCommit && this.generationId != null && this.memberId ? this.commit(true, callback) : callback(null), callback => this.leaveGroup(callback), callback => { this.connect(); callback(null); } ]); } else { logger.debug('no new Topic/Partitions'); this.scheduleTopicPartitionCheck(); } }); }, this.options.topicPartitionCheckInterval); } }; ConsumerGroup.prototype._checkTopicPartitionChange = function (callback) { this.client.loadMetadataForTopics(this.topics, (error, metadataResponse) => { if (error) { return callback(error); } const metadata = mapTopicToPartitions(metadataResponse[1].metadata); const topicOrPartitionsChanged = _.some(this.topicPartitionLength, function (numberOfPartitions, topic) { return numberOfPartitions !== _.get(metadata, `['${topic}'].length`, 0); }); callback(null, topicOrPartitionsChanged); }); }; function mapTopicToPartitions (metadata) { return _.mapValues(metadata, Object.keys); } ConsumerGroup.prototype.handleJoinGroup = function (joinGroupResponse, callback) { logger.debug('joinGroupResponse %j from %s', joinGroupResponse, this.client.clientId); if (!joinGroupResponse.memberId || !joinGroupResponse.generationId) { callback(new Error('Invalid joinGroupResponse: ' + JSON.stringify(joinGroupResponse))); return; } this.isLeader = joinGroupResponse.leaderId === joinGroupResponse.memberId; this.generationId = joinGroupResponse.generationId; this.memberId = joinGroupResponse.memberId; var groupAssignment; if (this.isLeader) { // assign partitions return this.assignPartitions(joinGroupResponse.groupProtocol, joinGroupResponse.members, callback); } callback(null, groupAssignment); }; ConsumerGroup.prototype.saveDefaultOffsets = function (topicPartitionList, callback) { var self = this; const offsetPayload = _(topicPartitionList) .cloneDeep() .map(tp => { tp.time = ACCEPTED_FROM_OFFSET[this.options.fromOffset]; return tp; }); self.getOffset().fetch(offsetPayload, function (error, result) { if (error) { return callback(error); } self.defaultOffsets = _.mapValues(result, function (partitionOffsets) { return _.mapValues(partitionOffsets, _.head); }); callback(null); }); }; ConsumerGroup.prototype.handleSyncGroup = function (syncGroupResponse, callback) { logger.debug('SyncGroup Response'); var self = this; var ownedTopics = Object.keys(syncGroupResponse.partitions); if (ownedTopics.length) { logger.debug('%s owns topics: ', self.client.clientId, syncGroupResponse.partitions); const topicPartitionList = createTopicPartitionList(syncGroupResponse.partitions); const useDefaultOffsets = self.options.fromOffset in ACCEPTED_FROM_OFFSET; let noOffset; async.waterfall( [ function (callback) { self.fetchOffset(syncGroupResponse.partitions, callback); }, function (offsets, callback) { logger.debug('%s fetchOffset Response: %j', self.client.clientId, offsets); noOffset = topicPartitionList.some(function (tp) { return offsets[tp.topic][tp.partition] === -1; }); if (noOffset) { logger.debug('No saved offsets'); if (self.options.fromOffset === 'none') { return callback( new Error( `${self.client.clientId} owns topics and partitions which contains no saved offsets for group '${ self.options.groupId }'` ) ); } async.parallel( [ function (callback) { if (useDefaultOffsets) { return self.saveDefaultOffsets(topicPartitionList, callback); } callback(null); } ], function (error) { if (error) { return callback(error); } logger.debug( '%s defaultOffset Response for %s: %j', self.client.clientId, self.options.fromOffset, self.defaultOffsets ); callback(null, offsets); } ); } else { logger.debug('Has saved offsets'); callback(null, offsets); } }, function (offsets, callback) { self.topicPayloads = self.buildPayloads(topicPartitionList).map(function (p) { var offset = offsets[p.topic][p.partition]; if (offset === -1) { // -1 means no offset was saved for this topic/partition combo offset = useDefaultOffsets ? self.getDefaultOffset(p, 0) : 0; } p.offset = offset; return p; }); self.payloadMap = self.buildPayloadMap(self.topicPayloads); if (noOffset && self.options.commitOffsetsOnFirstJoin) { self.commit(true, err => { callback(err, !err ? true : null); }); } else { callback(null, true); } } ], callback ); } else { self.topicPayloads = []; self.payloadMap = {}; // no partitions assigned callback(null, false); } }; ConsumerGroup.prototype.getDefaultOffset = function (tp, defaultOffset) { return _.get(this.defaultOffsets, [tp.topic, tp.partition], defaultOffset); }; ConsumerGroup.prototype.getOffset = function () { if (this.offset) { return this.offset; } this.offset = new Offset(this.client); // we can ignore this since we are already forwarding error event emitted from client this.offset.on('error', _.noop); return this.offset; }; function emptyStrIfNull (value) { return value == null ? '' : value; } ConsumerGroup.prototype.connect = function () { if (this.connecting) { logger.warn('Connect ignored. Currently connecting.'); return; } if (this.closed) { logger.warn('Connect ignored. Consumer closed.'); return; } logger.debug('Connecting %s', this.client.clientId); var self = this; this.connecting = true; this.emit('rebalancing'); async.waterfall( [ function (callback) { if (typeof self.options.onRebalance === 'function') { self.options.onRebalance(self.generationId != null && self.memberId != null, function (error) { if (error) { return callback(error); } callback(null); }); return; } callback(null); }, function (callback) { if (self.options.autoCommit && self.generationId != null && self.memberId) { self.commit(true, function (error) { if (error) { return callback(error); } callback(null); }); return; } callback(null); }, function (callback) { if (self.client.coordinatorId) { return callback(null, null); } self.client.sendGroupCoordinatorRequest(self.options.groupId, callback); }, function (coordinatorInfo, callback) { logger.debug('GroupCoordinator Response:', coordinatorInfo); if (coordinatorInfo) { self.setCoordinatorId(coordinatorInfo.coordinatorId); } self.client.sendJoinGroupRequest( self.options.groupId, emptyStrIfNull(self.memberId), self.options.sessionTimeout, self.protocols, callback ); }, function (joinGroupResponse, callback) { self.handleJoinGroup(joinGroupResponse, callback); }, function (groupAssignment, callback) { logger.debug('SyncGroup Request from %s', self.memberId); self.client.sendSyncGroupRequest( self.options.groupId, self.generationId, self.memberId, groupAssignment, callback ); }, function (syncGroupResponse, callback) { self.handleSyncGroup(syncGroupResponse, callback); } ], function (error, startFetch) { self.connecting = false; self.rebalancing = false; if (error) { return self.recovery.tryToRecoverFrom(error, 'connect'); } self.ready = true; self.recovery.clearError(); logger.debug('generationId', self.generationId); logger.debug('startFetch is', startFetch); self._resetFetchState(); if (startFetch) { self.clearPendingFetches(); self.fetch(); } self.scheduleTopicPartitionCheck(); self.startHeartbeats(); self.emit('connect'); self.emit('rebalanced'); } ); }; ConsumerGroup.prototype.clearPendingFetches = function () { _.forEach(this.client.getBrokers(true), broker => { if (broker.socket.waiting) { broker.socket.waiting = false; this.client.clearCallbackQueue(broker.socket); } }); }; ConsumerGroup.prototype.scheduleReconnect = function (timeout) { assert(timeout); this.rebalancing = true; if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } var self = this; this.reconnectTimer = setTimeout(function () { self.reconnectTimer = null; self.connect(); }, timeout); }; ConsumerGroup.prototype.startHeartbeats = function () { assert(this.options.sessionTimeout > 0); assert(this.ready, 'consumerGroup is not ready'); const heartbeatIntervalMs = this.options.heartbeatInterval || Math.floor(this.options.sessionTimeout / 3); logger.debug('%s started heartbeats at every %d ms', this.client.clientId, heartbeatIntervalMs); this.stopHeartbeats(); let heartbeat = this.sendHeartbeat(); this.heartbeatInterval = setInterval(() => { // only send another heartbeat if we got a response from the last one if (heartbeat.verifyResolved()) { heartbeat = this.sendHeartbeat(); } }, heartbeatIntervalMs); }; ConsumerGroup.prototype.stopHeartbeats = function () { this.heartbeatInterval && clearInterval(this.heartbeatInterval); }; ConsumerGroup.prototype.leaveGroup = function (callback) { logger.debug('%s leaving group', this.client.clientId); var self = this; this.stopHeartbeats(); if (self.generationId != null && self.memberId) { this.client.sendLeaveGroupRequest(this.options.groupId, this.memberId, function (error) { self.generationId = null; callback(error); }); } else { callback(null); } }; ConsumerGroup.prototype.sendHeartbeat = function () { assert(this.memberId, 'invalid memberId'); assert(this.generationId >= 0, 'invalid generationId'); // logger.debug('%s ❤️ ->', this.client.clientId); var self = this; function heartbeatCallback (error) { if (error) { logger.warn('%s Heartbeat error:', self.client.clientId, error); self.recovery.tryToRecoverFrom(error, 'heartbeat'); } // logger.debug('%s 💚 <-', self.client.clientId, error); } const heartbeat = new Heartbeat(this.client, heartbeatCallback); heartbeat.send(this.options.groupId, this.generationId, this.memberId); return heartbeat; }; ConsumerGroup.prototype.fetchOffset = function (payloads, cb) { this.client.sendOffsetFetchV1Request(this.options.groupId, payloads, cb); }; ConsumerGroup.prototype.sendOffsetCommitRequest = function (commits, cb) { if (this.generationId && this.memberId) { this.client.sendOffsetCommitV2Request(this.options.groupId, this.generationId, this.memberId, commits, cb); } else { cb(null, 'Nothing to be committed'); } }; ConsumerGroup.prototype.addTopics = function (topics, cb) { topics = Array.isArray(topics) ? topics : [topics]; if (!this.client.ready) { this.client.once('ready', () => this.addTopics(topics, cb)); return; } async.series( [ callback => this.client.topicExists(topics, callback), callback => this.options.autoCommit && this.generationId != null && this.memberId ? this.commit(true, callback) : callback(null), callback => this.leaveGroup(callback), callback => { this.topics = this.topics.concat(topics); this.setupProtocols(this.options.protocol); this.connect(); callback(null); } ], error => (error ? cb(error) : cb(null, `Add Topics ${topics.join(',')} Successfully`)) ); }; ConsumerGroup.prototype.removeTopics = function (topics, cb) { topics = typeof topics === 'string' ? [topics] : topics; async.series( [ callback => this.client.topicExists(topics, callback), callback => this.options.autoCommit && this.generationId != null && this.memberId ? this.commit(true, callback) : callback(null), callback => this.leaveGroup(callback), callback => { this.topics = _.difference(this.topics, topics); this.setupProtocols(this.options.protocol); this.connect(); callback(null); } ], error => (error ? cb(error) : cb(null, `Remove Topics ${topics.join(',')} Successfully`)) ); }; ConsumerGroup.prototype.buildPayloads = function (payloads) { var self = this; return payloads.map(function (p) { if (typeof p !== 'object') p = { topic: p }; p.partition = p.partition || 0; p.offset = p.offset || 0; p.maxBytes = self.options.fetchMaxBytes; p.metadata = 'm'; // metadata can be arbitrary return p; }); }; ConsumerGroup.prototype.buildPayloadMap = function (payloads) { const payloadMap = {}; payloads.forEach(({ topic, partition, offset }) => { payloadMap[topic] = payloadMap[topic] || {}; payloadMap[topic][partition] = offset; }); return payloadMap; }; /* * Update offset info in current payloads * @param {Object} Topic-partition-offset * @param {Boolean} Don't commit when initing consumer */ ConsumerGroup.prototype.updateOffsets = function (topics, initing) { this.topicPayloads.forEach(p => { if (!_.isEmpty(topics[p.topic]) && topics[p.topic][p.partition] !== undefined) { var offset = topics[p.topic][p.partition]; if (offset === -1) offset = 0; if (!initing) p.offset = offset + 1; else p.offset = offset; // Update the map this.needToCommit = true; } this.payloadMap[p.topic] = this.payloadMap[p.topic] || {}; this.payloadMap[p.topic][p.partition] = p.offset; }); if (this.options.autoCommit && !initing) { this.autoCommit(false, function (err) { err && logger.debug('auto commit offset', err); }); } }; ConsumerGroup.prototype._onFetchDone = function (topics) { this.updateOffsets(topics); if (--this._pendingFetches > 0) { return; } this._isFetchPending = false; if (!this.paused) { setImmediate(() => this.fetch()); } }; ConsumerGroup.prototype._resetFetchState = function () { this._pendingFetches = 0; this._isFetchPending = false; }; ConsumerGroup.prototype._onFetchProcessing = function () { this._pendingFetches++; }; ConsumerGroup.prototype.fetch = function () { if (!this.ready || this.rebalancing || this.paused || this.closing) { return; } if (this._isFetchPending) { return; } this._isFetchPending = true; this.client.sendFetchRequest( this, this.topicPayloads, this.options.fetchMaxWaitMs, this.options.fetchMinBytes, this.options.maxTickMessages ); }; ConsumerGroup.prototype.setOffset = function (topic, partition, offset) { this.topicPayloads.every(function (p) { // eslint-disable-next-line eqeqeq if (p.topic === topic && p.partition == partition) { p.offset = offset; return false; } return true; }); }; ConsumerGroup.prototype.pause = function () { this.paused = true; }; ConsumerGroup.prototype.resume = function () { this.paused = false; this.fetch(); }; function autoCommit (force, cb) { if (arguments.length === 1) { cb = force; force = false; } if (!force) { if (this.committing) return cb(null, 'Offset committing'); if (!this.needToCommit) return cb(null, 'Commit not needed'); } this.needToCommit = false; this.committing = true; setTimeout( function () { this.committing = false; }.bind(this), this.options.autoCommitIntervalMs ).unref(); var commits = this.topicPayloads.filter(function (p) { return p.offset !== -1; }); if (commits.length) { this.sendOffsetCommitRequest(commits, cb); } else { cb(null, 'Nothing to be committed'); } } ConsumerGroup.prototype.commit = ConsumerGroup.prototype.autoCommit = autoCommit; ConsumerGroup.prototype.close = function (force, cb) { var self = this; this.ready = false; this.stopHeartbeats(); clearTimeout(this.topicPartitionCheckTimer); if (typeof force === 'function') { cb = force; force = false; } async.series( [ function (callback) { if (force) { self.commit(true, callback); return; } callback(null); }, function (callback) { self.leaveGroup(function (error) { if (error) { logger.error('Leave group failed with', error); } callback(null); }); }, function (callback) { self.client.close(callback); } ], function (error) { if (error) { return cb(error); } self.closed = true; cb(null); } ); }; module.exports = ConsumerGroup;