kafkajs
Version:
A modern Apache Kafka client for node.js
385 lines (327 loc) • 10.9 kB
JavaScript
const Long = require('../../utils/long')
const isInvalidOffset = require('./isInvalidOffset')
const initializeConsumerOffsets = require('./initializeConsumerOffsets')
const {
events: { COMMIT_OFFSETS },
} = require('../instrumentationEvents')
const { keys, assign } = Object
const indexTopics = topics => topics.reduce((obj, topic) => assign(obj, { [topic]: {} }), {})
const PRIVATE = {
COMMITTED_OFFSETS: Symbol('private:OffsetManager:committedOffsets'),
}
module.exports = class OffsetManager {
/**
* @param {Object} options
* @param {import("../../../types").Cluster} options.cluster
* @param {import("../../../types").Broker} options.coordinator
* @param {import("../../../types").IMemberAssignment} options.memberAssignment
* @param {boolean} options.autoCommit
* @param {number | null} options.autoCommitInterval
* @param {number | null} options.autoCommitThreshold
* @param {{[topic: string]: { fromBeginning: boolean }}} options.topicConfigurations
* @param {import("../../instrumentation/emitter")} options.instrumentationEmitter
* @param {string} options.groupId
* @param {number} options.generationId
* @param {string} options.memberId
*/
constructor({
cluster,
coordinator,
memberAssignment,
autoCommit,
autoCommitInterval,
autoCommitThreshold,
topicConfigurations,
instrumentationEmitter,
groupId,
generationId,
memberId,
}) {
this.cluster = cluster
this.coordinator = coordinator
// memberAssignment format:
// {
// 'topic1': [0, 1, 2, 3],
// 'topic2': [0, 1, 2, 3, 4, 5],
// }
this.memberAssignment = memberAssignment
this.topicConfigurations = topicConfigurations
this.instrumentationEmitter = instrumentationEmitter
this.groupId = groupId
this.generationId = generationId
this.memberId = memberId
this.autoCommit = autoCommit
this.autoCommitInterval = autoCommitInterval
this.autoCommitThreshold = autoCommitThreshold
this.lastCommit = Date.now()
this.topics = keys(memberAssignment)
this.clearAllOffsets()
}
/**
* @param {string} topic
* @param {number} partition
* @returns {Long}
*/
nextOffset(topic, partition) {
if (!this.resolvedOffsets[topic][partition]) {
this.resolvedOffsets[topic][partition] = this.committedOffsets()[topic][partition]
}
let offset = this.resolvedOffsets[topic][partition]
if (isInvalidOffset(offset)) {
offset = '0'
}
return Long.fromValue(offset)
}
/**
* @returns {Promise<import("../../../types").Broker>}
*/
async getCoordinator() {
if (!this.coordinator.isConnected()) {
this.coordinator = await this.cluster.findBroker(this.coordinator)
}
return this.coordinator
}
/**
* @param {import("../../../types").TopicPartition} topicPartition
*/
resetOffset({ topic, partition }) {
this.resolvedOffsets[topic][partition] = this.committedOffsets()[topic][partition]
}
/**
* @param {import("../../../types").TopicPartitionOffset} topicPartitionOffset
*/
resolveOffset({ topic, partition, offset }) {
this.resolvedOffsets[topic][partition] = Long.fromValue(offset)
.add(1)
.toString()
}
/**
* @returns {Long}
*/
countResolvedOffsets() {
const committedOffsets = this.committedOffsets()
const subtractOffsets = (resolvedOffset, committedOffset) => {
const resolvedOffsetLong = Long.fromValue(resolvedOffset)
return isInvalidOffset(committedOffset)
? resolvedOffsetLong
: resolvedOffsetLong.subtract(Long.fromValue(committedOffset))
}
const subtractPartitionOffsets = (resolvedTopicOffsets, committedTopicOffsets) =>
keys(resolvedTopicOffsets).map(partition =>
subtractOffsets(resolvedTopicOffsets[partition], committedTopicOffsets[partition])
)
const subtractTopicOffsets = topic =>
subtractPartitionOffsets(this.resolvedOffsets[topic], committedOffsets[topic])
const offsetsDiff = this.topics.flatMap(subtractTopicOffsets)
return offsetsDiff.reduce((sum, offset) => sum.add(offset), Long.fromValue(0))
}
/**
* @param {import("../../../types").TopicPartition} topicPartition
*/
async setDefaultOffset({ topic, partition }) {
const { groupId, generationId, memberId } = this
const defaultOffset = this.cluster.defaultOffset(this.topicConfigurations[topic])
const coordinator = await this.getCoordinator()
await coordinator.offsetCommit({
groupId,
memberId,
groupGenerationId: generationId,
topics: [
{
topic,
partitions: [{ partition, offset: defaultOffset }],
},
],
})
this.clearOffsets({ topic, partition })
}
/**
* Commit the given offset to the topic/partition. If the consumer isn't assigned to the given
* topic/partition this method will be a NO-OP.
*
* @param {import("../../../types").TopicPartitionOffset} topicPartitionOffset
*/
async seek({ topic, partition, offset }) {
if (!this.memberAssignment[topic] || !this.memberAssignment[topic].includes(partition)) {
return
}
if (!this.autoCommit) {
this.resolveOffset({
topic,
partition,
offset: Long.fromValue(offset)
.subtract(1)
.toString(),
})
return
}
const { groupId, generationId, memberId } = this
const coordinator = await this.getCoordinator()
await coordinator.offsetCommit({
groupId,
memberId,
groupGenerationId: generationId,
topics: [
{
topic,
partitions: [{ partition, offset }],
},
],
})
this.clearOffsets({ topic, partition })
}
async commitOffsetsIfNecessary() {
const now = Date.now()
const timeoutReached =
this.autoCommitInterval != null && now >= this.lastCommit + this.autoCommitInterval
const thresholdReached =
this.autoCommitThreshold != null &&
this.countResolvedOffsets().gte(Long.fromValue(this.autoCommitThreshold))
if (timeoutReached || thresholdReached) {
return this.commitOffsets()
}
}
/**
* Return all locally resolved offsets which are not marked as committed, by topic-partition.
* @returns {import('../../../types').OffsetsByTopicPartition}
*/
uncommittedOffsets() {
const offsets = topic => keys(this.resolvedOffsets[topic])
const emptyPartitions = ({ partitions }) => partitions.length > 0
const toPartitions = topic => partition => ({
partition,
offset: this.resolvedOffsets[topic][partition],
})
const changedOffsets = topic => ({ partition, offset }) => {
return (
offset !== this.committedOffsets()[topic][partition] &&
Long.fromValue(offset).greaterThanOrEqual(0)
)
}
// Select and format updated partitions
const topicsWithPartitionsToCommit = this.topics
.map(topic => ({
topic,
partitions: offsets(topic)
.map(toPartitions(topic))
.filter(changedOffsets(topic)),
}))
.filter(emptyPartitions)
return { topics: topicsWithPartitionsToCommit }
}
async commitOffsets(offsets = {}) {
const { groupId, generationId, memberId } = this
const { topics = this.uncommittedOffsets().topics } = offsets
if (topics.length === 0) {
this.lastCommit = Date.now()
return
}
const payload = {
groupId,
memberId,
groupGenerationId: generationId,
topics,
}
try {
const coordinator = await this.getCoordinator()
await coordinator.offsetCommit(payload)
this.instrumentationEmitter.emit(COMMIT_OFFSETS, payload)
// Update local reference of committed offsets
topics.forEach(({ topic, partitions }) => {
const updatedOffsets = partitions.reduce(
(obj, { partition, offset }) => assign(obj, { [partition]: offset }),
{}
)
this[PRIVATE.COMMITTED_OFFSETS][topic] = assign(
{},
this.committedOffsets()[topic],
updatedOffsets
)
})
this.lastCommit = Date.now()
} catch (e) {
// metadata is stale, the coordinator has changed due to a restart or
// broker reassignment
if (e.type === 'NOT_COORDINATOR_FOR_GROUP') {
await this.cluster.refreshMetadata()
}
throw e
}
}
async resolveOffsets() {
const { groupId } = this
const invalidOffset = topic => partition => {
return isInvalidOffset(this.committedOffsets()[topic][partition])
}
const pendingPartitions = this.topics
.map(topic => ({
topic,
partitions: this.memberAssignment[topic]
.filter(invalidOffset(topic))
.map(partition => ({ partition })),
}))
.filter(t => t.partitions.length > 0)
if (pendingPartitions.length === 0) {
return
}
const coordinator = await this.getCoordinator()
const { responses: consumerOffsets } = await coordinator.offsetFetch({
groupId,
topics: pendingPartitions,
})
const unresolvedPartitions = consumerOffsets.map(({ topic, partitions }) =>
assign(
{
topic,
partitions: partitions
.filter(({ offset }) => isInvalidOffset(offset))
.map(({ partition }) => assign({ partition })),
},
this.topicConfigurations[topic]
)
)
const indexPartitions = (obj, { partition, offset }) => {
return assign(obj, { [partition]: offset })
}
const hasUnresolvedPartitions = () => unresolvedPartitions.some(t => t.partitions.length > 0)
let offsets = consumerOffsets
if (hasUnresolvedPartitions()) {
const topicOffsets = await this.cluster.fetchTopicsOffset(unresolvedPartitions)
offsets = initializeConsumerOffsets(consumerOffsets, topicOffsets)
}
offsets.forEach(({ topic, partitions }) => {
this.committedOffsets()[topic] = partitions.reduce(indexPartitions, {
...this.committedOffsets()[topic],
})
})
}
/**
* @private
* @param {import("../../../types").TopicPartition} topicPartition
*/
clearOffsets({ topic, partition }) {
delete this.committedOffsets()[topic][partition]
delete this.resolvedOffsets[topic][partition]
}
/**
* @private
*/
clearAllOffsets() {
const committedOffsets = this.committedOffsets()
for (const topic in committedOffsets) {
delete committedOffsets[topic]
}
for (const topic of this.topics) {
committedOffsets[topic] = {}
}
this.resolvedOffsets = indexTopics(this.topics)
}
committedOffsets() {
if (!this[PRIVATE.COMMITTED_OFFSETS]) {
this[PRIVATE.COMMITTED_OFFSETS] = this.groupId
? this.cluster.committedOffsets({ groupId: this.groupId })
: {}
}
return this[PRIVATE.COMMITTED_OFFSETS]
}
}