rocketmq-client-nodejs
Version:
RocketMQ Node.js Client
565 lines (488 loc) • 20.9 kB
text/typescript
/**
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ClientType, MessageType, Permission } from '../../proto/apache/rocketmq/v2/definition_pb';
import {
AckMessageRequest,
ChangeInvisibleDurationRequest,
ForwardMessageToDeadLetterQueueRequest,
HeartbeatRequest,
NotifyClientTerminationRequest,
QueryAssignmentRequest,
ReceiveMessageRequest,
} from '../../proto/apache/rocketmq/v2/service_pb';
import { MessageView } from '../message';
import { Broker, MessageQueue, TopicRouteData } from '../route';
import { MessageQueue as MessageQueuePB, Broker as BrokerPB } from '../../proto/apache/rocketmq/v2/definition_pb';
import { StatusChecker } from '../exception';
import { RetryPolicy } from '../retry';
import { createDuration, createResource } from '../util';
import { Consumer, ConsumerOptions } from './Consumer';
import { FilterExpression } from './FilterExpression';
import { PushSubscriptionSettings } from './PushSubscriptionSettings';
import { ConsumeService } from './ConsumeService';
import { StandardConsumeService } from './StandardConsumeService';
import { FifoConsumeService } from './FifoConsumeService';
import { ProcessQueue } from './ProcessQueue';
import { Assignment } from './Assignment';
import { Assignments } from './Assignments';
import { MessageListener } from './MessageListener';
const ASSIGNMENT_SCAN_SCHEDULE_DELAY = 1000;
const ASSIGNMENT_SCAN_SCHEDULE_PERIOD = 5000;
export interface PushConsumerOptions extends ConsumerOptions {
subscriptions: Map<string/* topic */, FilterExpression | string>;
messageListener: MessageListener;
maxCacheMessageCount?: number;
maxCacheMessageSizeInBytes?: number;
longPollingTimeout?: number;
enableFifoConsumeAccelerator?: boolean;
}
class ConsumeMetrics {
receptionTimes = 0;
receivedMessagesQuantity = 0;
consumptionOkQuantity = 0;
consumptionErrorQuantity = 0;
}
export class PushConsumer extends Consumer {
readonly #pushSubscriptionSettings: PushSubscriptionSettings;
readonly #subscriptionExpressions = new Map<string, FilterExpression>();
readonly #cacheAssignments = new Map<string, Assignments>();
readonly #messageListener: MessageListener;
readonly #maxCacheMessageCount: number;
readonly #maxCacheMessageSizeInBytes: number;
readonly #enableFifoConsumeAccelerator: boolean;
readonly #processQueueTable = new Map<string /* mq key */, { mq: MessageQueue; pq: ProcessQueue }>();
readonly #metrics = new ConsumeMetrics();
#consumeService!: ConsumeService;
#scanAssignmentTimer?: NodeJS.Timeout;
constructor(options: PushConsumerOptions) {
options.topics = Array.from(options.subscriptions.keys());
super(options);
for (const [ topic, filter ] of options.subscriptions.entries()) {
if (typeof filter === 'string') {
this.#subscriptionExpressions.set(topic, new FilterExpression(filter));
} else {
this.#subscriptionExpressions.set(topic, filter);
}
}
this.#messageListener = options.messageListener;
this.#maxCacheMessageCount = options.maxCacheMessageCount ?? 1024;
this.#maxCacheMessageSizeInBytes = options.maxCacheMessageSizeInBytes ?? 64 * 1024 * 1024;
this.#enableFifoConsumeAccelerator = options.enableFifoConsumeAccelerator ?? false;
this.#pushSubscriptionSettings = new PushSubscriptionSettings(
options.namespace, this.clientId, this.getClientType(), this.endpoints,
this.consumerGroup, this.requestTimeout, this.#subscriptionExpressions,
);
}
async startup() {
this.logger.info('Begin to start the rocketmq push consumer, clientId=%s, consumerGroup=%s',
this.clientId, this.consumerGroup);
await super.startup();
this.logger.info('Super startup completed, clientId=%s', this.clientId);
try {
this.#consumeService = this.createConsumeService();
// Start scanning assignments periodically
this.startAssignmentScanning();
this.logger.info('Push consumer started successfully, clientId=%s', this.clientId);
} catch (err) {
this.logger.error('Failed to start push consumer, cleaning up resources, clientId=%s, error=%s',
this.clientId, err);
// Clean up timers if initialization fails
if (this.#scanAssignmentTimer) {
clearInterval(this.#scanAssignmentTimer);
this.#scanAssignmentTimer = undefined;
}
throw err;
}
}
/**
* Start assignment scanning. Can be overridden by subclasses.
*/
protected startAssignmentScanning() {
this.logger.info('Starting assignment scanning, clientId=%s', this.clientId);
setTimeout(() => this.#scanAssignments(), ASSIGNMENT_SCAN_SCHEDULE_DELAY);
this.#scanAssignmentTimer = setInterval(() => this.#scanAssignments(), ASSIGNMENT_SCAN_SCHEDULE_PERIOD);
}
async shutdown() {
this.logger.info('Begin to shutdown the rocketmq push consumer, clientId=%s', this.clientId);
// Stop scanning assignments
if (this.#scanAssignmentTimer) {
clearInterval(this.#scanAssignmentTimer);
this.#scanAssignmentTimer = undefined;
}
// Drop all process queues
this.logger.info('Dropping all process queues, clientId=%s, queueCount=%d',
this.clientId, this.#processQueueTable.size);
for (const { pq } of this.#processQueueTable.values()) {
pq.drop();
pq.abort();
}
this.#processQueueTable.clear();
// Shutdown consume service
if (this.#consumeService) {
this.logger.info('Shutting down consume service, clientId=%s', this.clientId);
this.#consumeService.abort();
}
await super.shutdown();
this.logger.info('Push consumer has been shutdown successfully, clientId=%s', this.clientId);
}
protected getSettings() {
return this.#pushSubscriptionSettings;
}
/**
* Get the client type for push consumer.
*
* @return The client type identifier
*/
protected getClientType(): ClientType {
return ClientType.PUSH_CONSUMER;
}
protected wrapHeartbeatRequest() {
return new HeartbeatRequest()
.setClientType(this.getClientType())
.setGroup(createResource(this.consumerGroup));
}
protected wrapNotifyClientTerminationRequest() {
return new NotifyClientTerminationRequest()
.setGroup(createResource(this.consumerGroup));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
protected onTopicRouteDataUpdate(_topic: string, _topicRouteData: TopicRouteData) {
// No-op for push consumer; assignments are queried separately
}
protected createConsumeService(): ConsumeService {
if (this.#pushSubscriptionSettings.isFifo()) {
return new FifoConsumeService(this.clientId, this.#messageListener, this.#enableFifoConsumeAccelerator);
}
return new StandardConsumeService(this.clientId, this.#messageListener);
}
async subscribe(topic: string, filterExpression: FilterExpression) {
await this.getRouteData(topic);
this.#subscriptionExpressions.set(topic, filterExpression);
}
unsubscribe(topic: string) {
this.#subscriptionExpressions.delete(topic);
}
// --- Public methods for ProcessQueue access ---
get requestTimeoutValue(): number {
return this.requestTimeout;
}
getPushConsumerSettings(): PushSubscriptionSettings {
return this.#pushSubscriptionSettings;
}
getConsumerGroup(): string {
return this.consumerGroup;
}
getConsumeService(): ConsumeService {
return this.#consumeService;
}
getRetryPolicy(): RetryPolicy | undefined {
return this.#pushSubscriptionSettings.getRetryPolicy();
}
getClientId(): string {
return this.clientId;
}
// --- Metrics access ---
getReceptionTimes(): number {
return this.#metrics.receptionTimes;
}
getReceivedMessagesQuantity(): number {
return this.#metrics.receivedMessagesQuantity;
}
getConsumptionOkQuantity(): number {
return this.#metrics.consumptionOkQuantity;
}
getConsumptionErrorQuantity(): number {
return this.#metrics.consumptionErrorQuantity;
}
incrementReceptionTimes() {
this.#metrics.receptionTimes++;
}
incrementReceivedMessagesQuantity(count: number) {
this.#metrics.receivedMessagesQuantity += count;
}
incrementConsumptionOkQuantity() {
this.#metrics.consumptionOkQuantity++;
}
incrementConsumptionErrorQuantity() {
this.#metrics.consumptionErrorQuantity++;
}
// --- Statistics ---
doStats() {
const receptionTimes = this.#metrics.receptionTimes;
this.#metrics.receptionTimes = 0;
const receivedMessagesQuantity = this.#metrics.receivedMessagesQuantity;
this.#metrics.receivedMessagesQuantity = 0;
const consumptionOkQuantity = this.#metrics.consumptionOkQuantity;
this.#metrics.consumptionOkQuantity = 0;
const consumptionErrorQuantity = this.#metrics.consumptionErrorQuantity;
this.#metrics.consumptionErrorQuantity = 0;
this.logger.info('clientId=%s, consumerGroup=%s, receptionTimes=%d, receivedMessagesQuantity=%d, '
+ 'consumptionOkQuantity=%d, consumptionErrorQuantity=%d',
this.clientId, this.consumerGroup, receptionTimes, receivedMessagesQuantity,
consumptionOkQuantity, consumptionErrorQuantity);
}
wrapPushReceiveMessageRequest(
batchSize: number,
mq: MessageQueue,
filterExpression: FilterExpression,
longPollingTimeout: number,
attemptId?: string,
) {
const request = new ReceiveMessageRequest()
.setGroup(createResource(this.consumerGroup))
.setMessageQueue(mq.toProtobuf())
.setFilterExpression(filterExpression.toProtobuf())
.setLongPollingTimeout(createDuration(longPollingTimeout))
.setBatchSize(batchSize)
.setAutoRenew(true);
if (attemptId) {
request.setAttemptId(attemptId);
}
return request;
}
async receiveMessage(request: ReceiveMessageRequest, mq: MessageQueue, awaitDuration: number) {
return super.receiveMessage(request, mq, awaitDuration);
}
wrapAckMessageRequest(messageView: MessageView) {
const request = new AckMessageRequest()
.setGroup(createResource(this.consumerGroup))
.setTopic(createResource(messageView.topic));
const entry = request.addEntries()
.setMessageId(messageView.messageId)
.setReceiptHandle(messageView.receiptHandle);
// For lite consumers, must set liteTopic in ACK request
if (this.isLiteConsumer() && messageView.liteTopic) {
entry.setLiteTopic(messageView.liteTopic);
}
return request;
}
wrapChangeInvisibleDurationRequest(messageView: MessageView, invisibleDuration: number) {
const request = new ChangeInvisibleDurationRequest()
.setGroup(createResource(this.consumerGroup))
.setTopic(createResource(messageView.topic))
.setReceiptHandle(messageView.receiptHandle)
.setInvisibleDuration(createDuration(invisibleDuration))
.setMessageId(messageView.messageId);
// For lite consumers, must set liteTopic and suspend in request
if (this.isLiteConsumer() && messageView.liteTopic) {
request.setLiteTopic(messageView.liteTopic);
request.setSuspend(true);
}
return request;
}
wrapForwardMessageToDeadLetterQueueRequest(messageView: MessageView) {
const retryPolicy = this.getRetryPolicy();
const request = new ForwardMessageToDeadLetterQueueRequest()
.setGroup(createResource(this.consumerGroup))
.setTopic(createResource(messageView.topic))
.setReceiptHandle(messageView.receiptHandle)
.setMessageId(messageView.messageId)
.setDeliveryAttempt(messageView.deliveryAttempt ?? 0)
.setMaxDeliveryAttempts(retryPolicy?.getMaxAttempts() ?? 1);
// For lite consumers, must set liteTopic in request
if (this.isLiteConsumer() && messageView.liteTopic) {
request.setLiteTopic(messageView.liteTopic);
}
return request;
}
// --- Internal: queue size and cache threshold ---
getQueueSize(): number {
return this.#processQueueTable.size;
}
cacheMessageBytesThresholdPerQueue(): number {
const size = this.getQueueSize();
if (size <= 0) return 0;
return Math.max(1, Math.floor(this.#maxCacheMessageSizeInBytes / size));
}
cacheMessageCountThresholdPerQueue(): number {
const size = this.getQueueSize();
if (size <= 0) return 0;
return Math.max(1, Math.floor(this.#maxCacheMessageCount / size));
}
// --- Internal: assignment scanning ---
#scanAssignments() {
try {
this.logger.debug?.('Scanning assignments, clientId=%s, subscriptionCount=%d',
this.clientId, this.#subscriptionExpressions.size);
for (const [ topic, filterExpression ] of this.#subscriptionExpressions) {
const existed = this.#cacheAssignments.get(topic);
this.#queryAssignment(topic)
.then(latest => {
this.logger.debug?.('Query assignment result, topic=%s, clientId=%s, assignmentCount=%d',
topic, this.clientId, latest.getAssignmentList().length);
if (latest.getAssignmentList().length === 0) {
if (!existed || existed.getAssignmentList().length === 0) {
this.logger.info('Acquired empty assignments from remote, would scan later, topic=%s, '
+ 'clientId=%s', topic, this.clientId);
return;
}
this.logger.warn('Attention!!! acquired empty assignments from remote, but existed assignments'
+ ' is not empty, topic=%s, clientId=%s', topic, this.clientId);
}
if (!latest.equals(existed)) {
this.logger.info('Assignments of topic=%s has changed, %j => %j, clientId=%s', topic, existed,
latest, this.clientId);
this.#syncProcessQueue(topic, latest, filterExpression);
this.#cacheAssignments.set(topic, latest);
return;
}
this.logger.debug?.('Assignments of topic=%s remains the same, assignments=%j, clientId=%s', topic,
existed, this.clientId);
// Process queue may be dropped, need to be synchronized anyway.
this.#syncProcessQueue(topic, latest, filterExpression);
})
.catch(err => {
this.logger.error('Exception raised while scanning the assignments, topic=%s, clientId=%s, error=%s',
topic, this.clientId, err);
});
}
} catch (err) {
this.logger.error('Exception raised while scanning the assignments for all topics, clientId=%s, error=%s',
this.clientId, err);
}
}
async #queryAssignment(topic: string): Promise<Assignments> {
const topicRouteData = await this.getRouteData(topic);
const endpointsList = topicRouteData.getTotalEndpoints();
if (endpointsList.length === 0) {
throw new Error(`No endpoints available for topic=${topic}`);
}
const endpoints = endpointsList[0];
const request = new QueryAssignmentRequest()
.setTopic(createResource(topic))
.setGroup(createResource(this.consumerGroup))
.setEndpoints(this.endpoints.toProtobuf());
const response = await this.rpcClientManager.queryAssignment(
endpoints, request, this.requestTimeout,
);
const status = response.getStatus();
if (!status) {
throw new Error('Missing status in query assignment response');
}
StatusChecker.check(status.toObject());
const assignmentList = response.getAssignmentsList().map(assignment => {
const mqPb = assignment.getMessageQueue()!;
const mq = new MessageQueue(mqPb);
return new Assignment(mq);
});
return new Assignments(assignmentList);
}
#syncProcessQueue(topic: string, assignments: Assignments, filterExpression: FilterExpression) {
const latestMqKeys = new Set<string>();
for (const assignment of assignments.getAssignmentList()) {
latestMqKeys.add(this.#mqKey(assignment.messageQueue));
}
// Find active message queues
const activeMqKeys = new Set<string>();
for (const [ mqKey, { mq, pq }] of this.#processQueueTable) {
if (mq.topic.name !== topic) {
continue;
}
if (!latestMqKeys.has(mqKey)) {
this.logger.info('Drop message queue according to the latest assignmentList, mq=%s@%s@%s, clientId=%s',
mq.topic.name, mq.broker.name, mq.queueId, this.clientId);
this.#dropProcessQueue(mqKey);
continue;
}
if (pq.expired()) {
this.logger.warn('Drop message queue because it is expired, mq=%s@%s@%s, clientId=%s',
mq.topic.name, mq.broker.name, mq.queueId, this.clientId);
this.#dropProcessQueue(mqKey);
continue;
}
activeMqKeys.add(mqKey);
}
// Create new process queues for new assignments
for (const assignment of assignments.getAssignmentList()) {
const mqKey = this.#mqKey(assignment.messageQueue);
if (activeMqKeys.has(mqKey)) {
continue;
}
const processQueue = this.#createProcessQueue(assignment.messageQueue, filterExpression);
if (processQueue) {
this.logger.info('Start to fetch message from remote, mq=%s@%s@%s, clientId=%s',
assignment.messageQueue.topic.name, assignment.messageQueue.broker.name,
assignment.messageQueue.queueId, this.clientId);
processQueue.fetchMessageImmediately();
}
}
}
#mqKey(mq: MessageQueue): string {
return `${mq.topic.name}@${mq.broker.name}@${mq.queueId}`;
}
#dropProcessQueue(mqKey: string) {
const entry = this.#processQueueTable.get(mqKey);
if (entry) {
entry.pq.drop();
this.#processQueueTable.delete(mqKey);
}
}
#createProcessQueue(mq: MessageQueue, filterExpression: FilterExpression): ProcessQueue | null {
const mqKey = this.#mqKey(mq);
if (this.#processQueueTable.has(mqKey)) {
return null;
}
const processQueue = new ProcessQueue(this, mq, filterExpression);
this.#processQueueTable.set(mqKey, { mq, pq: processQueue });
return processQueue;
}
/**
* Create a virtual process queue for Lite consumer.
* Lite consumers use a special message queue with queueId=-1 to receive messages.
* This method creates the virtual queue and starts fetching messages immediately.
*
* @param topic - The bind topic name
* @param filterExpression - The filter expression for message filtering
*/
protected createVirtualProcessQueueForLite(topic: string, filterExpression: FilterExpression) {
// Validate parameters
if (!topic || topic.trim().length === 0) {
throw new Error('topic should not be blank');
}
if (!filterExpression) {
throw new Error('filterExpression should not be null');
}
// Create a virtual broker with default endpoints
const brokerPb = new BrokerPB();
brokerPb.setName('virtual-broker');
brokerPb.setId(0);
brokerPb.setEndpoints(this.endpoints.toProtobuf());
const broker = new Broker(brokerPb.toObject());
// Create a virtual message queue with queueId=-1
const virtualMqPb = new MessageQueuePB();
virtualMqPb.setId(-1); // Virtual queue ID for Lite consumer
virtualMqPb.setTopic(createResource(topic));
virtualMqPb.setBroker(broker.toProtobuf());
virtualMqPb.setPermission(Permission.READ);
virtualMqPb.setAcceptMessageTypesList([ MessageType.LITE ]);
const virtualMq = new MessageQueue(virtualMqPb);
// Create process queue for the virtual message queue
const mqKey = this.#mqKey(virtualMq);
if (this.#processQueueTable.has(mqKey)) {
this.logger.info('Virtual process queue already exists, mq=%s, clientId=%s', mqKey, this.clientId);
return;
}
const processQueue = new ProcessQueue(this, virtualMq, filterExpression);
this.#processQueueTable.set(mqKey, { mq: virtualMq, pq: processQueue });
this.logger.info('Created virtual process queue for Lite consumer, mq=%s, clientId=%s',
mqKey, this.clientId);
// Start fetching messages immediately
this.logger.info('Start to fetch lite messages from virtual queue, mq=%s, clientId=%s',
mqKey, this.clientId);
processQueue.fetchMessageImmediately();
}
}