UNPKG

rocketmq-client-nodejs

Version:
565 lines (488 loc) 20.9 kB
/** * 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(); } }