@confluentinc/kafka-javascript
Version:
Node.js bindings for librdkafka
1,358 lines (1,189 loc) • 78.2 kB
JavaScript
const LibrdKafkaError = require('../error');
const { Admin } = require('./_admin');
const error = require('./_error');
const RdKafka = require('../rdkafka');
const {
kafkaJSToRdKafkaConfig,
topicPartitionOffsetToRdKafka,
topicPartitionOffsetMetadataToRdKafka,
topicPartitionOffsetMetadataToKafkaJS,
createBindingMessageMetadata,
createKafkaJsErrorFromLibRdKafkaError,
notImplemented,
loggerTrampoline,
DefaultLogger,
CompatibilityErrorMessages,
severityToLogLevel,
checkAllowedKeys,
logLevel,
Lock,
partitionKey,
DeferredPromise,
Timer
} = require('./_common');
const { Buffer } = require('buffer');
const MessageCache = require('./_consumer_cache');
const { hrtime } = require('process');
const ConsumerState = Object.freeze({
INIT: 0,
CONNECTING: 1,
CONNECTED: 2,
DISCONNECTING: 3,
DISCONNECTED: 4,
});
/**
* A list of supported partition assignor types.
* @enum {string}
* @readonly
* @memberof KafkaJS
*/
const PartitionAssigners = {
roundRobin: 'roundrobin',
range: 'range',
cooperativeSticky: 'cooperative-sticky',
};
/**
* Consumer for reading messages from Kafka (promise-based, async API).
*
* The consumer allows reading messages from the Kafka cluster, and provides
* methods to configure and control various aspects of that. This class should
* not be instantiated directly, and rather, an instance of
* [Kafka]{@link KafkaJS.Kafka} should be used.
*
* @example
* const { Kafka } = require('@confluentinc/kafka-javascript');
* const kafka = new Kafka({ 'bootstrap.servers': 'localhost:9092' });
* const consumer = kafka.consumer({ 'group.id': 'test-group' });
* await consumer.connect();
* await consumer.subscribe({ topics: ["test-topic"] });
* consumer.run({
* eachMessage: async ({ topic, partition, message }) => { console.log({topic, partition, message}); }
* });
* @memberof KafkaJS
* @see [Consumer example]{@link https://github.com/confluentinc/confluent-kafka-javascript/blob/master/examples/consumer.js}
*/
class Consumer {
/**
* The config supplied by the user.
* @type {import("../../types/kafkajs").ConsumerConstructorConfig|null}
*/
#userConfig = null;
/**
* The config realized after processing any compatibility options.
* @type {import("../../types/config").ConsumerGlobalConfig|null}
*/
#internalConfig = null;
/**
* internalClient is the node-rdkafka client used by the API.
* @type {import("../rdkafka").Consumer|null}
*/
#internalClient = null;
/**
* connectPromiseFunc is the set of promise functions used to resolve/reject the connect() promise.
* @type {{resolve: Function, reject: Function}|{}}
*/
#connectPromiseFunc = {};
/**
* Stores the first error encountered while connecting (if any). This is what we
* want to reject with.
* @type {Error|null}
*/
#connectionError = null;
/**
* state is the current state of the consumer.
* @type {ConsumerState}
*/
#state = ConsumerState.INIT;
/**
* Contains a mapping of topic+partition to an offset that the user wants to seek to.
* The keys are of the type "<topic>|<partition>".
* @type {Map<string, number>}
*/
#pendingSeeks = new Map();
/**
* Stores the map of paused partitions keys to TopicPartition objects.
* @type {Map<string, TopicPartition>}
*/
#pausedPartitions = new Map();
/**
* Contains a list of stored topics/regexes that the user has subscribed to.
* @type {Array<string|RegExp>}
*/
#storedSubscriptions = [];
/**
* A logger for the consumer.
* @type {import("../../types/kafkajs").Logger}
*/
#logger = new DefaultLogger();
/**
* A map of topic+partition to the offset that was last consumed.
* The keys are of the type "<topic>|<partition>".
* @type {Map<string, number>}
*/
#lastConsumedOffsets = new Map();
/**
* A lock for consuming and disconnecting.
* This lock should be held whenever we want to change the state from CONNECTED to any state other than CONNECTED.
* In practical terms, this lock is held whenever we're consuming a message, or disconnecting.
* @type {Lock}
*/
#lock = new Lock();
/**
* Whether the consumer is running.
* @type {boolean}
*/
#running = false;
/**
* The message cache for KafkaJS compatibility mode.
* @type {MessageCache|null}
*/
#messageCache = null;
/**
* The maximum size of the message cache.
* Will be adjusted dynamically.
*/
#messageCacheMaxSize = 1;
/**
* Whether the user has enabled manual offset management (commits).
*/
#autoCommit = false;
/**
* Signals an intent to disconnect the consumer.
*/
#disconnectStarted = false;
/**
* Number of partitions owned by the consumer.
* @note This value may or may not be completely accurate, it's more so a hint for spawning concurrent workers.
*/
#partitionCount = 0;
/**
* Maximum batch size passed in eachBatch calls.
*/
#maxBatchSize = 32;
#maxBatchesSize = 32;
/**
* Maximum cache size in milliseconds per worker.
* Based on the consumer rate estimated through the eachMessage/eachBatch calls.
*
* @default 1500
*/
#maxCacheSizePerWorkerMs = 1500;
/**
* Whether worker termination has been scheduled.
*/
#workerTerminationScheduled = new DeferredPromise();
/**
* The worker functions currently running in the consumer.
*/
#workers = [];
/**
* The number of partitions to consume concurrently as set by the user, or 1.
*/
#concurrency = 1;
/**
* Promise that resolves together with last in progress fetch.
* It's set to null when no fetch is in progress.
*/
#fetchInProgress;
/**
* Are we waiting for the queue to be non-empty?
*/
#nonEmpty = null;
/**
* Whether any rebalance callback is in progress.
* That can last more than the fetch itself given it's not awaited.
* So we await it after fetch is done.
*/
#rebalanceCbInProgress;
/**
* Promise that is resolved on fetch to restart max poll interval timer.
*/
#maxPollIntervalRestart = new DeferredPromise();
/**
* Initial default value for max poll interval.
*/
#maxPollIntervalMs = 300000;
/**
* Maximum interval between poll calls from workers,
* if exceeded, the cache is cleared so a new poll can be made
* before reaching the max poll interval.
* It's set to max poll interval value.
*/
#cacheExpirationTimeoutMs = 300000;
/**
* Last fetch real time clock in nanoseconds.
*/
#lastFetchClockNs = 0n;
/**
* Last number of messages fetched.
*/
#lastFetchedMessageCnt = 0n;
/**
* Last fetch concurrency used.
*/
#lastFetchedConcurrency = 0n;
/**
* List of pending operations to be executed after
* all workers reach the end of their current processing.
*/
#pendingOperations = [];
/**
* Maps topic-partition key to the batch payload for marking staleness.
*
* Only used with eachBatch.
* NOTE: given that size of this map will never exceed #concurrency, a
* linear search might actually be faster over what will generally be <10 elems.
* But a map makes conceptual sense. Revise at a later point if needed.
*/
#topicPartitionToBatchPayload = new Map();
/**
* The client name used by the consumer for logging - determined by librdkafka
* using a combination of clientId and an integer.
* @type {string|undefined}
*/
#clientName = undefined;
// Convenience function to create the metadata object needed for logging.
#createConsumerBindingMessageMetadata() {
return createBindingMessageMetadata(this.#clientName);
}
/**
* This method should not be used directly. See {@link KafkaJS.Consumer}.
* @constructor
* @param {import("../../types/kafkajs").ConsumerConfig} kJSConfig
*/
constructor(kJSConfig) {
this.#userConfig = kJSConfig;
}
/**
* @returns {import("../rdkafka").Consumer | null} the internal node-rdkafka client.
* @note only for internal use and subject to API changes.
* @private
*/
_getInternalClient() {
return this.#internalClient;
}
/**
* Create a new admin client using the underlying connections of the consumer.
*
* The consumer must be connected before connecting the resulting admin client.
* The usage of the admin client is limited to the lifetime of the consumer.
* The consumer's logger is shared with the admin client.
* @returns {KafkaJS.Admin}
*/
dependentAdmin() {
return new Admin(null, this);
}
#config() {
if (!this.#internalConfig)
this.#internalConfig = this.#finalizedConfig();
return this.#internalConfig;
}
/**
* Clear the message cache, and reset to stored positions.
*
* @param {Array<{topic: string, partition: number}>|null} topicPartitions to clear the cache for, if null, then clear all assigned.
* @private
*/
async #clearCacheAndResetPositions() {
/* Seek to stored offset for each topic partition. It's possible that we've
* consumed messages upto N from the internalClient, but the user has stale'd the cache
* after consuming just k (< N) messages. We seek back to last consumed offset + 1. */
this.#messageCache.clear();
const clearPartitions = this.assignment();
const seeks = [];
for (const topicPartition of clearPartitions) {
const key = partitionKey(topicPartition);
if (!this.#lastConsumedOffsets.has(key))
continue;
const lastConsumedOffsets = this.#lastConsumedOffsets.get(key);
const topicPartitionOffsets = [
{
topic: topicPartition.topic,
partition: topicPartition.partition,
offset: lastConsumedOffsets.offset,
leaderEpoch: lastConsumedOffsets.leaderEpoch,
}
];
seeks.push(this.#seekInternal(topicPartitionOffsets));
}
await Promise.allSettled(seeks);
try {
await Promise.all(seeks);
} catch (err) {
/* TODO: we should cry more about this and render the consumer unusable. */
this.#logger.error(`Seek error. This is effectively a fatal error: ${err.stack}`);
}
}
#unassign(assignment) {
if (this.#internalClient.rebalanceProtocol() === "EAGER") {
this.#internalClient.unassign();
this.#messageCache.clear();
this.#partitionCount = 0;
} else {
this.#internalClient.incrementalUnassign(assignment);
this.#messageCache.markStale(assignment);
this.#partitionCount -= assignment.length;
}
}
/**
* Used as a trampoline to the user's rebalance listener, if any.
* @param {Error} err - error in rebalance
* @param {import("../../types").TopicPartition[]} assignment
* @private
*/
async #rebalanceCallback(err, assignment) {
const isLost = this.#internalClient.assignmentLost();
let assignmentFnCalled = false;
this.#logger.info(
`Received rebalance event with message: '${err.message}' and ${assignment.length} partition(s), isLost: ${isLost}`,
this.#createConsumerBindingMessageMetadata());
/* We allow the user to modify the assignment by returning it. If a truthy
* value is returned, we use that and do not apply any pending seeks to it either.
* The user can alternatively use the assignmentFns argument.
* Precedence is given to the calling of functions within assignmentFns. */
let assignmentModified = false;
const assignmentFn = (userAssignment) => {
if (assignmentFnCalled)
return;
assignmentFnCalled = true;
if (this.#internalClient.rebalanceProtocol() === "EAGER") {
this.#internalClient.assign(userAssignment);
this.#partitionCount = userAssignment.length;
} else {
this.#internalClient.incrementalAssign(userAssignment);
this.#partitionCount += userAssignment.length;
}
};
const unassignmentFn = (userAssignment) => {
if (assignmentFnCalled)
return;
assignmentFnCalled = true;
if (this.#disconnectStarted)
this.#unassign(userAssignment);
else
this.#addPendingOperation(() => this.#unassign(userAssignment));
};
try {
err = LibrdKafkaError.create(err);
const userSpecifiedRebalanceCb = this.#userConfig['rebalance_cb'];
if (typeof userSpecifiedRebalanceCb === 'function') {
const assignmentFns = {
assign: assignmentFn,
unassign: unassignmentFn,
assignmentLost: () => isLost,
};
let alternateAssignment = null;
try {
alternateAssignment = await userSpecifiedRebalanceCb(err, assignment, assignmentFns);
} catch (e) {
this.#logger.error(`Error from user's rebalance callback: ${e.stack}, `+
'continuing with the default rebalance behavior.');
}
if (alternateAssignment) {
assignment = alternateAssignment;
assignmentModified = true;
}
} else if (err.code !== LibrdKafkaError.codes.ERR__ASSIGN_PARTITIONS && err.code !== LibrdKafkaError.codes.ERR__REVOKE_PARTITIONS) {
throw new Error(`Unexpected rebalance_cb error code ${err.code}`);
}
} finally {
/* Emit the event */
this.#internalClient.emit('rebalance', err, assignment);
/**
* We never need to clear the cache in case of a rebalance.
* This is because rebalances are triggered ONLY when we call the consume()
* method of the internalClient.
* In case consume() is being called, we've already either consumed all the messages
* in the cache, or timed out (this.#messageCache.cachedTime is going to exceed max.poll.interval)
* and marked the cache stale. This means that the cache is always expired when a rebalance
* is triggered.
* This is applicable both for incremental and non-incremental rebalances.
* Multiple consume()s cannot be called together, too, because we make sure that only
* one worker is calling into the internal consumer at a time.
*/
try {
if (err.code === LibrdKafkaError.codes.ERR__ASSIGN_PARTITIONS) {
const checkPendingSeeks = this.#pendingSeeks.size !== 0;
if (checkPendingSeeks && !assignmentModified && !assignmentFnCalled)
assignment = this.#assignAsPerSeekedOffsets(assignment);
assignmentFn(assignment);
} else {
unassignmentFn(assignment);
}
} catch (e) {
// Ignore exceptions if we are not connected
if (this.#internalClient.isConnected()) {
this.#internalClient.emit('rebalance.error', e);
}
}
/**
* Schedule worker termination here, in case the number of workers is not equal to the target concurrency.
* We need to do this so we will respawn workers with the correct concurrency count.
*/
const workersToSpawn = Math.max(1, Math.min(this.#concurrency, this.#partitionCount));
if (workersToSpawn !== this.#workers.length) {
this.#resolveWorkerTerminationScheduled();
/* We don't need to await the workers here. We are OK if the termination and respawning
* occurs later, since even if we have a few more or few less workers for a while, it's
* not a big deal. */
}
this.#rebalanceCbInProgress.resolve();
}
}
#kafkaJSToConsumerConfig(kjsConfig, isClassicProtocol = true) {
if (!kjsConfig || Object.keys(kjsConfig).length === 0) {
return {};
}
const disallowedKey = checkAllowedKeys('consumer', kjsConfig);
if (disallowedKey !== null) {
throw new error.KafkaJSError(CompatibilityErrorMessages.unsupportedKey(disallowedKey),
{ code: error.ErrorCodes.ERR__INVALID_ARG });
}
const rdKafkaConfig = kafkaJSToRdKafkaConfig(kjsConfig);
this.#logger = new DefaultLogger();
/* Consumer specific configuration */
if (Object.hasOwn(kjsConfig, 'groupId')) {
rdKafkaConfig['group.id'] = kjsConfig.groupId;
}
if (Object.hasOwn(kjsConfig, 'partitionAssigners')) {
kjsConfig.partitionAssignors = kjsConfig.partitionAssigners;
}
if (Object.hasOwn(kjsConfig, 'partitionAssignors')) {
if (!isClassicProtocol) {
throw new error.KafkaJSError(
"partitionAssignors is not supported when group.protocol is not 'classic'.",
{ code: error.ErrorCodes.ERR__INVALID_ARG }
);
}
if (!Array.isArray(kjsConfig.partitionAssignors)) {
throw new error.KafkaJSError(CompatibilityErrorMessages.partitionAssignors(), { code: error.ErrorCodes.ERR__INVALID_ARG });
}
kjsConfig.partitionAssignors.forEach(assignor => {
if (typeof assignor !== 'string')
throw new error.KafkaJSError(CompatibilityErrorMessages.partitionAssignors(), { code: error.ErrorCodes.ERR__INVALID_ARG });
});
rdKafkaConfig['partition.assignment.strategy'] = kjsConfig.partitionAssignors.join(',');
} else if (isClassicProtocol) {
rdKafkaConfig['partition.assignment.strategy'] = PartitionAssigners.roundRobin;
}
if (Object.hasOwn(kjsConfig, 'sessionTimeout')) {
if (!isClassicProtocol) {
throw new error.KafkaJSError(
"sessionTimeout is not supported when group.protocol is not 'classic'.",
{ code: error.ErrorCodes.ERR__INVALID_ARG }
);
}
rdKafkaConfig['session.timeout.ms'] = kjsConfig.sessionTimeout;
} else if (isClassicProtocol) {
rdKafkaConfig['session.timeout.ms'] = 30000;
}
if (Object.hasOwn(kjsConfig, 'heartbeatInterval')) {
if (!isClassicProtocol) {
throw new error.KafkaJSError(
"heartbeatInterval is not supported when group.protocol is not 'classic'.",
{ code: error.ErrorCodes.ERR__INVALID_ARG }
);
}
rdKafkaConfig['heartbeat.interval.ms'] = kjsConfig.heartbeatInterval;
}
if (Object.hasOwn(kjsConfig, 'rebalanceTimeout')) {
/* In librdkafka, we use the max poll interval as the rebalance timeout as well. */
rdKafkaConfig['max.poll.interval.ms'] = +kjsConfig.rebalanceTimeout;
} else if (!rdKafkaConfig['max.poll.interval.ms']) {
rdKafkaConfig['max.poll.interval.ms'] = 300000; /* librdkafka default */
}
if (Object.hasOwn(kjsConfig, 'metadataMaxAge')) {
rdKafkaConfig['topic.metadata.refresh.interval.ms'] = kjsConfig.metadataMaxAge;
}
if (Object.hasOwn(kjsConfig, 'allowAutoTopicCreation')) {
rdKafkaConfig['allow.auto.create.topics'] = kjsConfig.allowAutoTopicCreation;
} else {
rdKafkaConfig['allow.auto.create.topics'] = true;
}
if (Object.hasOwn(kjsConfig, 'maxBytesPerPartition')) {
rdKafkaConfig['max.partition.fetch.bytes'] = kjsConfig.maxBytesPerPartition;
} else {
rdKafkaConfig['max.partition.fetch.bytes'] = 1048576;
}
if (Object.hasOwn(kjsConfig, 'maxWaitTimeInMs')) {
rdKafkaConfig['fetch.wait.max.ms'] = kjsConfig.maxWaitTimeInMs;
}
if (Object.hasOwn(kjsConfig, 'minBytes')) {
rdKafkaConfig['fetch.min.bytes'] = kjsConfig.minBytes;
}
if (Object.hasOwn(kjsConfig, 'maxBytes')) {
rdKafkaConfig['fetch.message.max.bytes'] = kjsConfig.maxBytes;
} else {
rdKafkaConfig['fetch.message.max.bytes'] = 10485760;
}
if (Object.hasOwn(kjsConfig, 'readUncommitted')) {
rdKafkaConfig['isolation.level'] = kjsConfig.readUncommitted ? 'read_uncommitted' : 'read_committed';
}
if (Object.hasOwn(kjsConfig, 'maxInFlightRequests')) {
rdKafkaConfig['max.in.flight'] = kjsConfig.maxInFlightRequests;
}
if (Object.hasOwn(kjsConfig, 'rackId')) {
rdKafkaConfig['client.rack'] = kjsConfig.rackId;
}
if (Object.hasOwn(kjsConfig, 'fromBeginning')) {
rdKafkaConfig['auto.offset.reset'] = kjsConfig.fromBeginning ? 'earliest' : 'latest';
}
if (Object.hasOwn(kjsConfig, 'autoCommit')) {
rdKafkaConfig['enable.auto.commit'] = kjsConfig.autoCommit;
} else {
rdKafkaConfig['enable.auto.commit'] = true;
}
if (Object.hasOwn(kjsConfig, 'autoCommitInterval')) {
rdKafkaConfig['auto.commit.interval.ms'] = kjsConfig.autoCommitInterval;
}
if (Object.hasOwn(kjsConfig, 'autoCommitThreshold')) {
throw new error.KafkaJSError(CompatibilityErrorMessages.runOptionsAutoCommitThreshold(), { code: error.ErrorCodes.ERR__NOT_IMPLEMENTED });
}
/* Set the logger */
if (Object.hasOwn(kjsConfig, 'logger')) {
this.#logger = kjsConfig.logger;
}
/* Set the log level - INFO for compatibility with kafkaJS, or DEBUG if that is turned
* on using the logLevel property. rdKafkaConfig.log_level is guaranteed to be set if we're
* here, and containing the correct value. */
this.#logger.setLogLevel(severityToLogLevel[rdKafkaConfig.log_level]);
return rdKafkaConfig;
}
#finalizedConfig() {
const protocol = this.#userConfig['group.protocol'];
const isClassicProtocol = protocol === undefined ||
(typeof protocol === 'string' && protocol.toLowerCase() === 'classic');
/* Creates an rdkafka config based off the kafkaJS block. Switches to compatibility mode if the block exists. */
let compatibleConfig = this.#kafkaJSToConsumerConfig(this.#userConfig.kafkaJS, isClassicProtocol);
/* There can be multiple different and conflicting config directives for setting the log level:
* 1. If there's a kafkaJS block:
* a. If there's a logLevel directive in the kafkaJS block, set the logger level accordingly.
* b. If there's no logLevel directive, set the logger level to INFO.
* (both these are already handled in the conversion method above).
* 2. If there is a log_level or debug directive in the main config, set the logger level accordingly.
* !This overrides any different value provided in the kafkaJS block!
* a. If there's a log_level directive, set the logger level accordingly.
* b. If there's a debug directive, set the logger level to DEBUG regardless of anything else. This is because
* librdkafka ignores log_level if debug is set, and our behaviour should be identical.
* 3. There's nothing at all. Take no action in this case, let the logger use its default log level.
*/
if (Object.hasOwn(this.#userConfig, 'log_level')) {
this.#logger.setLogLevel(severityToLogLevel[this.#userConfig.log_level]);
}
if (Object.hasOwn(this.#userConfig, 'debug')) {
this.#logger.setLogLevel(logLevel.DEBUG);
}
let rdKafkaConfig = Object.assign(compatibleConfig, this.#userConfig);
/* Delete properties which are already processed, or cannot be passed to node-rdkafka */
delete rdKafkaConfig.kafkaJS;
/* Certain properties that the user has set are overridden. We use trampolines to accommodate the user's callbacks.
* TODO: add trampoline method for offset commit callback. */
rdKafkaConfig['offset_commit_cb'] = true;
rdKafkaConfig['rebalance_cb'] = (err, assignment) => {
this.#rebalanceCbInProgress = new DeferredPromise();
this.#rebalanceCallback(err, assignment).catch(e =>
{
if (this.#logger)
this.#logger.error(`Error from rebalance callback: ${e.stack}`);
});
};
/* We handle offset storage within the promisified API by ourselves. Thus we don't allow the user to change this
* setting and set it to false. */
if (Object.hasOwn(this.#userConfig, 'enable.auto.offset.store')) {
throw new error.KafkaJSError(
"Changing 'enable.auto.offset.store' is unsupported while using the promisified API.",
{ code: error.ErrorCodes.ERR__INVALID_ARG });
}
rdKafkaConfig['enable.auto.offset.store'] = false;
if (!Object.hasOwn(rdKafkaConfig, 'enable.auto.commit')) {
this.#autoCommit = true; /* librdkafka default. */
} else {
this.#autoCommit = rdKafkaConfig['enable.auto.commit'];
}
/**
* Actual max poll interval is twice the configured max poll interval,
* because we want to ensure that when we ask for worker termination,
* and there is one last message to be processed, we can process it in
* the configured max poll interval time.
* This will cause the rebalance callback timeout to be double
* the value of the configured max poll interval.
* But it's expected otherwise we cannot have a cache and need to consider
* max poll interval reached on processing the very first message.
*/
this.#maxPollIntervalMs = rdKafkaConfig['max.poll.interval.ms'] ?? 300000;
this.#cacheExpirationTimeoutMs = this.#maxPollIntervalMs;
rdKafkaConfig['max.poll.interval.ms'] = this.#maxPollIntervalMs * 2;
if (Object.hasOwn(rdKafkaConfig, 'js.consumer.max.batch.size')) {
const maxBatchSize = +rdKafkaConfig['js.consumer.max.batch.size'];
if (!Number.isInteger(maxBatchSize) || (maxBatchSize <= 0 && maxBatchSize !== -1)) {
throw new error.KafkaJSError(
"'js.consumer.max.batch.size' must be a positive integer or -1 for unlimited batch size.",
{ code: error.ErrorCodes.ERR__INVALID_ARG });
}
this.#maxBatchSize = maxBatchSize;
this.#maxBatchesSize = maxBatchSize;
if (maxBatchSize === -1) {
this.#messageCacheMaxSize = Number.MAX_SAFE_INTEGER;
}
delete rdKafkaConfig['js.consumer.max.batch.size'];
}
if (Object.hasOwn(rdKafkaConfig, 'js.consumer.max.cache.size.per.worker.ms')) {
const maxCacheSizePerWorkerMs = +rdKafkaConfig['js.consumer.max.cache.size.per.worker.ms'];
if (!Number.isInteger(maxCacheSizePerWorkerMs) || (maxCacheSizePerWorkerMs <= 0)) {
throw new error.KafkaJSError(
"'js.consumer.max.cache.size.per.worker.ms' must be a positive integer.",
{ code: error.ErrorCodes.ERR__INVALID_ARG });
}
this.#maxCacheSizePerWorkerMs = maxCacheSizePerWorkerMs;
delete rdKafkaConfig['js.consumer.max.cache.size.per.worker.ms'];
}
if (Object.hasOwn(rdKafkaConfig, 'stats_cb')) {
if (typeof rdKafkaConfig['stats_cb'] === 'function')
this.#statsCb = rdKafkaConfig['stats_cb'];
delete rdKafkaConfig['stats_cb'];
}
return rdKafkaConfig;
}
#readyCb() {
if (this.#state !== ConsumerState.CONNECTING) {
/* The connectPromiseFunc might not be set, so we throw such an error. It's a state error that we can't recover from. Probably a bug. */
throw new error.KafkaJSError(`Ready callback called in invalid state ${this.#state}`, { code: error.ErrorCodes.ERR__STATE });
}
this.#state = ConsumerState.CONNECTED;
/* Slight optimization for cases where the size of messages in our subscription is less than the cache size. */
this.#internalClient.setDefaultIsTimeoutOnlyForFirstMessage(true);
// We will fetch only those messages which are already on the queue. Since we will be
// woken up by #queueNonEmptyCb, we don't need to set a wait timeout.
this.#internalClient.setDefaultConsumeTimeout(0);
this.#clientName = this.#internalClient.name;
this.#logger.info('Consumer connected', this.#createConsumerBindingMessageMetadata());
// Resolve the promise.
this.#connectPromiseFunc['resolve']();
}
/**
* Callback for the event.error event, either fails the initial connect(), or logs the error.
* @param {Error} err
* @private
*/
#errorCb(err) {
/* If we get an error in the middle of connecting, reject the promise later with this error. */
if (this.#state < ConsumerState.CONNECTED) {
if (!this.#connectionError)
this.#connectionError = err;
} else {
this.#logger.error(err, this.#createConsumerBindingMessageMetadata());
}
}
/**
* Callback for the event.stats event, if defined.
* @private
*/
#statsCb = null;
/**
* Converts headers returned by node-rdkafka into a format that can be used by the eachMessage/eachBatch callback.
* @param {import("../..").MessageHeader[] | undefined} messageHeaders
* @returns {import("../../types/kafkajs").IHeaders}
* @private
*/
#createHeaders(messageHeaders) {
let headers;
if (messageHeaders) {
headers = {};
for (const header of messageHeaders) {
for (const [key, value] of Object.entries(header)) {
if (!Object.hasOwn(headers, key)) {
headers[key] = value;
} else if (headers[key].constructor === Array) {
headers[key].push(value);
} else {
headers[key] = [headers[key], value];
}
}
}
}
return headers;
}
/**
* Converts a message returned by node-rdkafka into a message that can be used by the eachMessage callback.
* @param {import("../..").Message} message
* @returns {import("../../types/kafkajs").EachMessagePayload}
* @private
*/
#createPayload(message, worker) {
let key = message.key;
if (typeof key === 'string') {
key = Buffer.from(key);
}
let timestamp = message.timestamp ? String(message.timestamp) : '';
const headers = this.#createHeaders(message.headers);
return {
topic: message.topic,
partition: message.partition,
message: {
key,
value: message.value,
timestamp,
attributes: 0,
offset: String(message.offset),
size: message.size,
leaderEpoch: message.leaderEpoch,
headers
},
heartbeat: async () => { /* no op */ },
pause: this.pause.bind(this, [{ topic: message.topic, partitions: [message.partition] }]),
_worker: worker,
};
}
/**
* Method used by #createBatchPayload to resolve offsets.
* Resolution stores the offset into librdkafka if needed, and into the lastConsumedOffsets map
* that we use for seeking to the last consumed offset when forced to clear cache.
*
* @param {*} payload The payload we're creating. This is a method attached to said object.
* @param {*} offsetToResolve The offset to resolve.
* @param {*} leaderEpoch The leader epoch of the message (optional). We expect users to provide it, but for API-compatibility reasons, it's optional.
* @private
*/
#eachBatchPayload_resolveOffsets(payload, offsetToResolve, leaderEpoch = -1) {
const offset = +offsetToResolve;
if (isNaN(offset)) {
/* Not much we can do but throw and log an error. */
const e = new error.KafkaJSError(`Invalid offset to resolve: ${offsetToResolve}`, { code: error.ErrorCodes.ERR__INVALID_ARG });
throw e;
}
/* The user might resolve offset N (< M) after resolving offset M. Given that in librdkafka we can only
* store one offset, store the last possible one. */
if (offset <= payload._lastResolvedOffset.offset)
return;
const topic = payload.batch.topic;
const partition = payload.batch.partition;
payload._lastResolvedOffset = { offset, leaderEpoch };
try {
this.#internalClient._offsetsStoreSingle(
topic,
partition,
offset + 1,
leaderEpoch);
} catch (e) {
/* Not much we can do, except log the error. */
this.#logger.error(`Consumer encountered error while storing offset. Error details: ${e}:${e.stack}`, this.#createConsumerBindingMessageMetadata());
}
}
/**
* Method used by #createBatchPayload to commit offsets.
* @private
*/
async #eachBatchPayload_commitOffsetsIfNecessary() {
if (this.#autoCommit) {
/* librdkafka internally handles committing of whatever we store.
* We don't worry about it here. */
return;
}
/* If the offsets are being resolved by the user, they've already called resolveOffset() at this point
* We just need to commit the offsets that we've stored. */
await this.commitOffsets();
}
/**
* Converts a list of messages returned by node-rdkafka into a message that can be used by the eachBatch callback.
* @param {import("../..").Message[]} messages - must not be empty. Must contain messages from the same topic and partition.
* @returns {import("../../types/kafkajs").EachBatchPayload}
* @private
*/
#createBatchPayload(messages, worker) {
const topic = messages[0].topic;
const partition = messages[0].partition;
let watermarkOffsets = {};
let highWatermark = '-1001';
let offsetLag_ = -1;
let offsetLagLow_ = -1;
try {
watermarkOffsets = this.#internalClient.getWatermarkOffsets(topic, partition);
} catch (e) {
/* Only warn. The batch as a whole remains valid but for the fact that the highwatermark won't be there. */
this.#logger.warn(`Could not get watermark offsets for batch: ${e}`, this.#createConsumerBindingMessageMetadata());
}
/* Keep default values if it's not a real offset (OFFSET_INVALID: -1001). */
if (Number.isInteger(watermarkOffsets.highOffset) &&
watermarkOffsets.highOffset >= 0) {
highWatermark = watermarkOffsets.highOffset.toString();
/* While calculating lag, we subtract 1 from the high offset
* for compatibility reasons with KafkaJS's API */
offsetLag_ = (watermarkOffsets.highOffset - 1) - messages[messages.length - 1].offset;
offsetLagLow_ = (watermarkOffsets.highOffset - 1) - messages[0].offset;
/* In any case don't return a negative lag but a zero lag instead. */
offsetLag_ = offsetLag_ > 0 ? offsetLag_ : 0;
offsetLagLow_ = offsetLagLow_ > 0 ? offsetLagLow_ : 0;
}
const messagesConverted = [];
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
let key = message.key;
if (typeof key === 'string') {
key = Buffer.from(key);
}
let timestamp = message.timestamp ? String(message.timestamp) : '';
const headers = this.#createHeaders(message.headers);
const messageConverted = {
key,
value: message.value,
timestamp,
attributes: 0,
offset: String(message.offset),
size: message.size,
leaderEpoch: message.leaderEpoch,
headers
};
messagesConverted.push(messageConverted);
}
const batch = {
topic,
partition,
highWatermark,
messages: messagesConverted,
isEmpty: () => false,
firstOffset: () => (messagesConverted[0].offset).toString(),
lastOffset: () => (messagesConverted[messagesConverted.length - 1].offset).toString(),
offsetLag: () => offsetLag_.toString(),
offsetLagLow: () => offsetLagLow_.toString(),
};
const returnPayload = {
batch,
_stale: false,
_seeked: false,
_lastResolvedOffset: { offset: -1, leaderEpoch: -1 },
_worker: worker,
heartbeat: async () => { /* no op */ },
pause: this.pause.bind(this, [{ topic, partitions: [partition] }]),
commitOffsetsIfNecessary: this.#eachBatchPayload_commitOffsetsIfNecessary.bind(this),
isRunning: () => this.#running,
isStale: () => returnPayload._stale,
/* NOTE: Probably never to be implemented. Not sure exactly how we'd compute this
* inexpensively. */
uncommittedOffsets: () => notImplemented(),
};
returnPayload.resolveOffset = this.#eachBatchPayload_resolveOffsets.bind(this, returnPayload);
return returnPayload;
}
#updateMaxMessageCacheSize() {
if (this.#maxBatchSize === -1) {
// In case of unbounded max batch size it returns all available messages
// for a partition in each batch. Cache is unbounded given that
// it takes only one call to process each partition.
return;
}
const nowNs = hrtime.bigint();
if (this.#lastFetchedMessageCnt > 0 && this.#lastFetchClockNs > 0n &&
nowNs > this.#lastFetchClockNs) {
const consumptionDurationMilliseconds = Number(nowNs - this.#lastFetchClockNs) / 1e6;
const messagesPerMillisecondSingleWorker = this.#lastFetchedMessageCnt / this.#lastFetchedConcurrency / consumptionDurationMilliseconds;
// Keep enough messages in the cache for this.#maxCacheSizePerWorkerMs of concurrent consumption by all workers.
// Round up to the nearest multiple of `#maxBatchesSize`.
this.#messageCacheMaxSize = Math.ceil(
Math.round(this.#maxCacheSizePerWorkerMs * messagesPerMillisecondSingleWorker) * this.#concurrency
/ this.#maxBatchesSize
) * this.#maxBatchesSize;
}
}
#saveFetchStats(messages) {
this.#lastFetchClockNs = hrtime.bigint();
const partitionsNum = new Map();
for (const msg of messages) {
const key = partitionKey(msg);
partitionsNum.set(key, 1);
if (partitionsNum.size >= this.#concurrency) {
break;
}
}
this.#lastFetchedConcurrency = partitionsNum.size;
this.#lastFetchedMessageCnt = messages.length;
}
async #fetchAndResolveWith(takeFromCache, size) {
if (this.#fetchInProgress) {
await this.#fetchInProgress;
/* Restart with the checks as we might have
* a new fetch in progress already. */
return null;
}
if (this.#nonEmpty) {
await this.#nonEmpty;
/* Restart with the checks as we might have
* a new fetch in progress already. */
return null;
}
if (this.#workerTerminationScheduled.resolved) {
/* Return without fetching. */
return null;
}
let err, messages, processedRebalance = false;
try {
this.#fetchInProgress = new DeferredPromise();
const fetchResult = new DeferredPromise();
this.#logger.debug(`Attempting to fetch ${size} messages to the message cache`,
this.#createConsumerBindingMessageMetadata());
this.#updateMaxMessageCacheSize();
this.#internalClient.consume(size, (err, messages) =>
fetchResult.resolve([err, messages]));
[err, messages] = await fetchResult;
if (this.#rebalanceCbInProgress) {
processedRebalance = true;
await this.#rebalanceCbInProgress;
this.#rebalanceCbInProgress = null;
}
if (err) {
throw createKafkaJsErrorFromLibRdKafkaError(err);
}
this.#messageCache.addMessages(messages);
const res = takeFromCache();
this.#saveFetchStats(messages);
this.#maxPollIntervalRestart.resolve();
return res;
} finally {
this.#fetchInProgress.resolve();
this.#fetchInProgress = null;
if (!err && !processedRebalance && this.#messageCache.assignedSize === 0)
this.#nonEmpty = new DeferredPromise();
}
}
/**
* Consumes a single message from the internal consumer.
* @param {PerPartitionCache} ppc Per partition cache to use or null|undefined .
* @returns {Promise<import("../..").Message | null>} a promise that resolves to a single message or null.
* @note this method caches messages as well, but returns only a single message.
* @private
*/
async #consumeSingleCached(ppc) {
const msg = this.#messageCache.next(ppc);
if (msg) {
return msg;
}
/* It's possible that we get msg = null, but that's because partitionConcurrency
* exceeds the number of partitions containing messages. So
* we should wait for a new partition to be available.
*/
if (!msg && this.#messageCache.assignedSize !== 0) {
await this.#messageCache.availablePartitions();
/* Restart with the checks as we might have
* the cache full. */
return null;
}
return this.#fetchAndResolveWith(() => this.#messageCache.next(),
this.#messageCacheMaxSize);
}
/**
* Consumes a single message from the internal consumer.
* @param {number} savedIndex - the index of the message in the cache to return.
* @param {number} size - the number of messages to fetch.
* @returns {Promise<import("../..").Message[] | null>} a promise that resolves to a list of messages or null.
* @note this method caches messages as well.
* @sa #consumeSingleCached
* @private
*/
async #consumeCachedN(ppc, size) {
const msgs = this.#messageCache.nextN(ppc, size);
if (msgs) {
return msgs;
}
/* It's possible that we get msgs = null, but that's because partitionConcurrency
* exceeds the number of partitions containing messages. So
* we should wait for a new partition to be available.
*/
if (!msgs && this.#messageCache.assignedSize !== 0) {
await this.#messageCache.availablePartitions();
/* Restart with the checks as we might have
* the cache full. */
return null;
}
return this.#fetchAndResolveWith(() =>
this.#messageCache.nextN(null, size),
this.#messageCacheMaxSize);
}
/**
* Flattens a list of topics with partitions into a list of topic, partition.
* @param {Array<({topic: string, partitions: Array<number>}|{topic: string, partition: number})>} topics
* @returns {import("../../types/rdkafka").TopicPartition[]} a list of (topic, partition).
* @private
*/
#flattenTopicPartitions(topics) {
const ret = [];
for (const topic of topics) {
if (typeof topic.partition === 'number')
ret.push({
topic: topic.topic,
partition: topic.partition
});
else {
for (const partition of topic.partitions) {
ret.push({ topic: topic.topic, partition });
}
}
}
return ret;
}
/**
* Set up the client and connect to the bootstrap brokers.
*
* This method can be called only once for a consumer instance, and must be
* called before doing any other operations.
*
* @returns {Promise<void>} a promise that resolves when the consumer is connected.
*/
async connect() {
if (this.#state !== ConsumerState.INIT) {
throw new error.KafkaJSError('Connect has already been called elsewhere.', { code: error.ErrorCodes.ERR__STATE });
}
const rdKafkaConfig = this.#config();
this.#state = ConsumerState.CONNECTING;
rdKafkaConfig.queue_non_empty_cb = this.#queueNonEmptyCb.bind(this);
this.#internalClient = new RdKafka.KafkaConsumer(rdKafkaConfig);
this.#internalClient.on('ready', this.#readyCb.bind(this));
this.#internalClient.on('error', this.#errorCb.bind(this));
this.#internalClient.on('event.error', this.#errorCb.bind(this));
this.#internalClient.on('event.log', (msg) => loggerTrampoline(msg, this.#logger));
if (this.#statsCb) {
this.#internalClient.on('event.stats', this.#statsCb.bind(this));
}
return new Promise((resolve, reject) => {
this.#connectPromiseFunc = { resolve, reject };
this.#internalClient.connect(null, (err) => {
if (err) {
this.#state = ConsumerState.DISCONNECTED;
const rejectionError = this.#connectionError ? this.#connectionError : err;
reject(createKafkaJsErrorFromLibRdKafkaError(rejectionError));
}
});
});
}
/**
* Subscribes the consumer to the given topics.
* @param {object} subscription - An object containing the topic(s) to subscribe to - one of `topic` or `topics` must be present.
* @param {string?} subscription.topic - The topic to subscribe to.
* @param {Array<string>?} subscription.topics - The topics to subscribe to.
* @param {boolean?} subscription.replace - Whether to replace the existing subscription, or to add to it. Adds by default.
*/
async subscribe(subscription) {
if (this.#state !== ConsumerState.CONNECTED) {
throw new error.KafkaJSError('Subscribe can only be called while connected.', { code: error.ErrorCodes.ERR__STATE });
}
if (typeof subscription.fromBeginning === 'boolean') {
throw new error.KafkaJSError(
CompatibilityErrorMessages.subscribeOptionsFromBeginning(),
{ code: error.ErrorCodes.ERR__INVALID_ARG });
}
if (!Object.hasOwn(subscription, 'topics') && !Object.hasOwn(subscription, 'topic')) {
throw new error.KafkaJSError(CompatibilityErrorMessages.subscribeOptionsMandatoryMissing(), { code: error.ErrorCodes.ERR__INVALID_ARG });
}
let topics = [];
if (subscription.topic) {
topics.push(subscription.topic);
} else if (Array.isArray(subscription.topics)) {
topics = subscription.topics;
} else {
throw new error.KafkaJSError(CompatibilityErrorMessages.subscribeOptionsMandatoryMissing(), { code: error.ErrorCodes.ERR__INVALID_ARG });
}
topics = topics.map(topic => {
if (typeof topic === 'string') {
return topic;
} else if (topic instanceof RegExp) {
// Flags are not supported, and librdkafka only considers a regex match if the first character of the regex is ^.
if (topic.flags) {
throw new error.KafkaJSError(CompatibilityErrorMessages.subscribeOptionsRegexFlag(), { code: error.ErrorCodes.ERR__INVALID_ARG });
}
const regexSource = topic.source;
if (regexSource.charAt(0) !== '^')
throw new error.KafkaJSError(CompatibilityErrorMessages.subscribeOptionsRegexStart(), { code: error.ErrorCodes.ERR__INVALID_ARG });
return regexSource;
} else {
throw new error.KafkaJSError('Invalid topic ' + topic + ' (' + typeof topic + '), the topic name has to be a String or a RegExp', { code: error.ErrorCodes.ERR__INVALID_ARG });
}
});
this.#storedSubscriptions = subscription.replace ? topics : this.#storedSubscriptions.concat(topics);
this.#logger.debug(`${subscription.replace ? 'Replacing' : 'Adding'} topics [${topics.join(', ')}] to subscription`, this.#createConsumerBindingMessageMetadata());
this.#internalClient.subscribe(this.#storedSubscriptions);
}
async stop() {
notImplemented();
}
/**
* Starts consumer polling. This method returns immediately.
* @param {object} config - The configuration for running the consumer.
* @param {function?} config.eachMessage - The function to call for processing each message.
* @param {function?} config.eachBatch - The function to call for processing each batch of messages - can only be set if eachMessage is not set.
* @param {boolean?} config.eachBatchAutoResolve - Whether to automatically resolve offsets for each batch (only applicable if eachBatch is set, true by default).
* @param {number?} config.partitionsConsumedConcurrently - The limit to the number of partitions consumed concurrently (1 by default).
*/
async run(config) {
if (this.#state !== ConsumerState.CONNECTED) {
throw new error.KafkaJSError('Run must be called after a successful connect().', { code: error.ErrorCodes.ERR__STATE });
}
if (Object.hasOwn(config, 'autoCommit')) {
throw new error.KafkaJSError(CompatibilityErrorMessages.runOptionsAutoCommit(), { code: error.ErrorCodes.ERR__INVALID_ARG });
}
if (Object.hasOwn(config, 'autoCommitInterval')) {
throw new error.KafkaJSError(CompatibilityErrorMessages.runOptionsAutoCommitInterval(), { code: error.ErrorCodes.ERR__INVALID_ARG });
}
if (Object.hasOwn(config, 'autoCommitThreshold')) {
throw new error.KafkaJSError(CompatibilityErrorMessages.runOptionsAutoCommitThreshold(), { code: error.ErrorCodes.ERR__NOT_IMPLEMENTED });
}
if (this.#running) {
throw new error.KafkaJSError('Consumer is already running.', { code: error.ErrorCodes.ERR__STATE });
}
this.#running = true;
/* We're going to add keys to the configuration, so make a copy */
const configCopy = Object.assign({}, config);
/* Batches are auto resolved by default. */
if (!Object.hasOwn(config, 'eachBatchAutoResolve')) {
configCopy.eachBatchAutoResolve = true;
}
if (!Object.hasOwn(config, 'partitionsConsumedConcurrently')) {
configCopy.partitionsConsumedConcurrently = 1;
}
this.#messageCache = new MessageCache(this.#logger);
/* We deliberately don't await this because we want to return from this method immediately. */
this.#runInternal(configCopy);
}
/**
* Processes a single message.
*
* @param m Message as obtained from #consumeSingleCached.
* @param config Config as passed to run().
* @returns {Promise<number>} The cache index of the message that was processed.
* @private
*/
async #messageProcessor(m, config, worker) {
let ppc;
[m, ppc] = m;
let key = partitionKey(m);
let eachMessageProcessed = false;
const payload = this.#createPayload(m, worker);
try {
this.#lastConsumedOffsets.set(key, m);
await config.eachMessage(payload);
eachMessageProcessed = true;
} catch (e) {
/* It's not only possible, but expected that an error will be thrown by eachMessage.
* This is especially true since the pattern of pause() followed by throwing an error
* is encouraged. To meet the API contract, we seek one offset backward (which
* means seeking to the message offset).
* However, we don't do this inside the catch, but just outside it. This is because throwing an
* error is not the only case where we might want to seek back.
*
* So - do nothing but a log, but at this point eachMessageProcessed is false.
* TODO: log error only if error type is not KafkaJSError and if no pause() has been called, else log debug.
*/
this.#logger.error(
`Consumer encountered error while processing message. Error details: ${e}: ${e.stack}. The same message may be reprocessed.`,
this.#createConsumerBindingMessageMetadata());
}
/* If the message is unprocessed, due to an error, or because the user has not resolved it, we seek back. */
if (!eachMessageProcessed) {
this.seek({
topic: m.topic,
partition: m.partition,
offset: m.offset,
leaderEpoch: m.leaderEpoch,
});
}
/* Store the offsets we need to store, or at least record them for cache invalidation reasons. */
if (eachMessageProcessed) {
try {
this.#internalClient.