kafkajs
Version:
A modern Apache Kafka client for node.js
312 lines (271 loc) • 9.42 kB
JavaScript
const { EventEmitter } = require('events')
const SocketRequest = require('./socketRequest')
const events = require('../instrumentationEvents')
const { KafkaJSInvariantViolation } = require('../../errors')
const PRIVATE = {
EMIT_QUEUE_SIZE_EVENT: Symbol('private:RequestQueue:emitQueueSizeEvent'),
EMIT_REQUEST_QUEUE_EMPTY: Symbol('private:RequestQueue:emitQueueEmpty'),
}
const REQUEST_QUEUE_EMPTY = 'requestQueueEmpty'
module.exports = class RequestQueue extends EventEmitter {
/**
* @param {Object} options
* @param {number} options.maxInFlightRequests
* @param {number} options.requestTimeout
* @param {boolean} options.enforceRequestTimeout
* @param {string} options.clientId
* @param {string} options.broker
* @param {import("../../../types").Logger} options.logger
* @param {import("../../instrumentation/emitter")} [options.instrumentationEmitter=null]
* @param {() => boolean} [options.isConnected]
*/
constructor({
instrumentationEmitter = null,
maxInFlightRequests,
requestTimeout,
enforceRequestTimeout,
clientId,
broker,
logger,
isConnected = () => true,
}) {
super()
this.instrumentationEmitter = instrumentationEmitter
this.maxInFlightRequests = maxInFlightRequests
this.requestTimeout = requestTimeout
this.enforceRequestTimeout = enforceRequestTimeout
this.clientId = clientId
this.broker = broker
this.logger = logger
this.isConnected = isConnected
this.inflight = new Map()
this.pending = []
/**
* Until when this request queue is throttled and shouldn't send requests
*
* The value represents the timestamp of the end of the throttling in ms-since-epoch. If the value
* is smaller than the current timestamp no throttling is active.
*
* @type {number}
*/
this.throttledUntil = -1
/**
* Timeout id if we have scheduled a check for pending requests due to client-side throttling
*
* @type {null|NodeJS.Timeout}
*/
this.throttleCheckTimeoutId = null
this[PRIVATE.EMIT_REQUEST_QUEUE_EMPTY] = () => {
if (this.pending.length === 0 && this.inflight.size === 0) {
this.emit(REQUEST_QUEUE_EMPTY)
}
}
this[PRIVATE.EMIT_QUEUE_SIZE_EVENT] = () => {
instrumentationEmitter &&
instrumentationEmitter.emit(events.NETWORK_REQUEST_QUEUE_SIZE, {
broker: this.broker,
clientId: this.clientId,
queueSize: this.pending.length,
})
this[PRIVATE.EMIT_REQUEST_QUEUE_EMPTY]()
}
}
/**
* @public
*/
scheduleRequestTimeoutCheck() {
if (this.enforceRequestTimeout) {
this.destroy()
this.requestTimeoutIntervalId = setInterval(() => {
this.inflight.forEach(request => {
if (Date.now() - request.sentAt > request.requestTimeout) {
request.timeoutRequest()
}
})
if (!this.isConnected()) {
this.destroy()
}
}, Math.min(this.requestTimeout, 100))
}
}
maybeThrottle(clientSideThrottleTime) {
if (clientSideThrottleTime) {
const minimumThrottledUntil = Date.now() + clientSideThrottleTime
this.throttledUntil = Math.max(minimumThrottledUntil, this.throttledUntil)
}
}
/**
* @typedef {Object} PushedRequest
* @property {import("./socketRequest").RequestEntry} entry
* @property {boolean} expectResponse
* @property {Function} sendRequest
* @property {number} [requestTimeout]
*
* @public
* @param {PushedRequest} pushedRequest
*/
push(pushedRequest) {
const { correlationId } = pushedRequest.entry
const defaultRequestTimeout = this.requestTimeout
const customRequestTimeout = pushedRequest.requestTimeout
// Some protocol requests have custom request timeouts (e.g JoinGroup, Fetch, etc). The custom
// timeouts are influenced by user configurations, which can be lower than the default requestTimeout
const requestTimeout = Math.max(defaultRequestTimeout, customRequestTimeout || 0)
const socketRequest = new SocketRequest({
entry: pushedRequest.entry,
expectResponse: pushedRequest.expectResponse,
broker: this.broker,
clientId: this.clientId,
instrumentationEmitter: this.instrumentationEmitter,
requestTimeout,
send: () => {
if (this.inflight.has(correlationId)) {
throw new KafkaJSInvariantViolation('Correlation id already exists')
}
this.inflight.set(correlationId, socketRequest)
pushedRequest.sendRequest()
},
timeout: () => {
this.inflight.delete(correlationId)
this.checkPendingRequests()
// Try to emit REQUEST_QUEUE_EMPTY. Otherwise, waitForPendingRequests may stuck forever
this[PRIVATE.EMIT_REQUEST_QUEUE_EMPTY]()
},
})
if (this.canSendSocketRequestImmediately()) {
this.sendSocketRequest(socketRequest)
return
}
this.pending.push(socketRequest)
this.scheduleCheckPendingRequests()
this.logger.debug(`Request enqueued`, {
clientId: this.clientId,
broker: this.broker,
correlationId,
})
this[PRIVATE.EMIT_QUEUE_SIZE_EVENT]()
}
/**
* @param {SocketRequest} socketRequest
*/
sendSocketRequest(socketRequest) {
socketRequest.send()
if (!socketRequest.expectResponse) {
this.logger.debug(`Request does not expect a response, resolving immediately`, {
clientId: this.clientId,
broker: this.broker,
correlationId: socketRequest.correlationId,
})
this.inflight.delete(socketRequest.correlationId)
socketRequest.completed({ size: 0, payload: null })
}
}
/**
* @public
* @param {object} response
* @param {number} response.correlationId
* @param {Buffer} response.payload
* @param {number} response.size
*/
fulfillRequest({ correlationId, payload, size }) {
const socketRequest = this.inflight.get(correlationId)
this.inflight.delete(correlationId)
this.checkPendingRequests()
if (socketRequest) {
socketRequest.completed({ size, payload })
} else {
this.logger.warn(`Response without match`, {
clientId: this.clientId,
broker: this.broker,
correlationId,
})
}
this[PRIVATE.EMIT_REQUEST_QUEUE_EMPTY]()
}
/**
* @public
* @param {Error} error
*/
rejectAll(error) {
const requests = [...this.inflight.values(), ...this.pending]
for (const socketRequest of requests) {
socketRequest.rejected(error)
this.inflight.delete(socketRequest.correlationId)
}
this.pending = []
this.inflight.clear()
this[PRIVATE.EMIT_QUEUE_SIZE_EVENT]()
}
/**
* @public
*/
waitForPendingRequests() {
return new Promise(resolve => {
if (this.pending.length === 0 && this.inflight.size === 0) {
return resolve()
}
this.logger.debug('Waiting for pending requests', {
clientId: this.clientId,
broker: this.broker,
currentInflightRequests: this.inflight.size,
currentPendingQueueSize: this.pending.length,
})
this.once(REQUEST_QUEUE_EMPTY, () => resolve())
})
}
/**
* @public
*/
destroy() {
clearInterval(this.requestTimeoutIntervalId)
clearTimeout(this.throttleCheckTimeoutId)
this.throttleCheckTimeoutId = null
}
canSendSocketRequestImmediately() {
const shouldEnqueue =
(this.maxInFlightRequests != null && this.inflight.size >= this.maxInFlightRequests) ||
this.throttledUntil > Date.now()
return !shouldEnqueue
}
/**
* Check and process pending requests either now or in the future
*
* This function will send out as many pending requests as possible taking throttling and
* in-flight limits into account.
*/
checkPendingRequests() {
while (this.pending.length > 0 && this.canSendSocketRequestImmediately()) {
const pendingRequest = this.pending.shift() // first in first out
this.sendSocketRequest(pendingRequest)
this.logger.debug(`Consumed pending request`, {
clientId: this.clientId,
broker: this.broker,
correlationId: pendingRequest.correlationId,
pendingDuration: pendingRequest.pendingDuration,
currentPendingQueueSize: this.pending.length,
})
this[PRIVATE.EMIT_QUEUE_SIZE_EVENT]()
}
this.scheduleCheckPendingRequests()
}
/**
* Ensure that pending requests will be checked in the future
*
* If there is a client-side throttling in place this will ensure that we will check
* the pending request queue eventually.
*/
scheduleCheckPendingRequests() {
// If we're throttled: Schedule checkPendingRequests when the throttle
// should be resolved. If there is already something scheduled we assume that that
// will be fine, and potentially fix up a new timeout if needed at that time.
// Note that if we're merely "overloaded" by having too many inflight requests
// we will anyways check the queue when one of them gets fulfilled.
const timeUntilUnthrottled = this.throttledUntil - Date.now()
if (timeUntilUnthrottled > 0 && !this.throttleCheckTimeoutId) {
this.throttleCheckTimeoutId = setTimeout(() => {
this.throttleCheckTimeoutId = null
this.checkPendingRequests()
}, timeUntilUnthrottled)
}
}
}