UNPKG

@confluentinc/kafka-javascript

Version:
1,358 lines (1,189 loc) 78.2 kB
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.