rocketmq-client-nodejs
Version:
RocketMQ Node.js Client
416 lines (373 loc) • 17.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 assert from 'node:assert';
import { setTimeout } from 'node:timers/promises';
import {
ClientType,
MessageType,
TransactionResolution,
TransactionSource,
Code,
} from '../../proto/apache/rocketmq/v2/definition_pb';
import {
EndTransactionRequest,
HeartbeatRequest,
NotifyClientTerminationRequest,
RecoverOrphanedTransactionCommand,
SendMessageRequest,
} from '../../proto/apache/rocketmq/v2/service_pb';
import {
Endpoints,
MessageQueue,
TopicRouteData,
} from '../route';
import {
ExponentialBackoffRetryPolicy,
} from '../retry';
import { StatusChecker, TooManyRequestsException } from '../exception';
import { BaseClient, BaseClientOptions, Settings } from '../client';
import { PublishingMessage, MessageOptions, MessageView, Message } from '../message';
import { PublishingSettings } from './PublishingSettings';
import { TransactionChecker } from './TransactionChecker';
import { PublishingLoadBalancer } from './PublishingLoadBalancer';
import { SendReceipt } from './SendReceipt';
import { Transaction } from './Transaction';
import { createResource } from '../util';
import { RecallReceipt } from './RecallReceipt';
import { RecallMessageRequest } from '../../proto/apache/rocketmq/v2/service_pb';
export interface ProducerOptions extends BaseClientOptions {
topic?: string | string[];
maxAttempts?: number;
checker?: TransactionChecker;
}
export class Producer extends BaseClient {
#publishingSettings: PublishingSettings;
#checker?: TransactionChecker;
#publishingRouteDataCache = new Map<string, PublishingLoadBalancer>();
constructor(options: ProducerOptions) {
if (!options.topics && options.topic) {
options.topics = Array.isArray(options.topic) ? options.topic : [ options.topic ];
}
super(options);
// https://rocketmq.apache.org/docs/introduction/03limits/
// Default max number of message sending retries is 3
const retryPolicy = ExponentialBackoffRetryPolicy.immediatelyRetryPolicy(options.maxAttempts ?? 3);
this.#publishingSettings = new PublishingSettings(options.namespace, this.clientId, this.endpoints, retryPolicy,
this.requestTimeout, this.topics);
this.#checker = options.checker;
}
async startup() {
this.logger.info('Begin to start the rocketmq producer, clientId=%s', this.clientId);
await super.startup();
this.logger.info('The rocketmq producer starts successfully, clientId=%s', this.clientId);
}
async shutdown() {
this.logger.info('Begin to shutdown the rocketmq producer, clientId=%s', this.clientId);
await super.shutdown();
this.logger.info('Shutdown the rocketmq producer successfully, clientId=%s', this.clientId);
}
get publishingSettings() {
return this.#publishingSettings;
}
beginTransaction() {
assert(this.#checker, 'Transaction checker should not be null');
// Check producer status before beginning transaction
if (!this.isRunning()) {
this.logger.error('Unable to begin a transaction because producer is not running, clientId=%s', this.clientId);
throw new Error('Producer is not running now');
}
return new Transaction(this);
}
async endTransaction(endpoints: Endpoints, message: Message, messageId: string,
transactionId: string, resolution: TransactionResolution, source: TransactionSource = TransactionSource.SOURCE_CLIENT) {
const resolutionStr = resolution === TransactionResolution.COMMIT ? 'COMMIT' : 'ROLLBACK';
const sourceStr = TransactionSource[source];
this.logger.debug?.('Begin to end transaction, messageId=%s, transactionId=%s, resolution=%s, source=%s, clientId=%s',
messageId, transactionId, resolutionStr, sourceStr, this.clientId);
const request = new EndTransactionRequest()
.setMessageId(messageId)
.setTransactionId(transactionId)
.setTopic(createResource(message.topic).setResourceNamespace(this.namespace))
.setResolution(resolution)
.setSource(source);
const response = await this.rpcClientManager.endTransaction(endpoints, request, this.requestTimeout);
StatusChecker.check(response.getStatus()?.toObject());
this.logger.debug?.('End transaction successfully, messageId=%s, transactionId=%s, resolution=%s, source=%s, clientId=%s',
messageId, transactionId, resolutionStr, sourceStr, this.clientId);
}
async onRecoverOrphanedTransactionCommand(endpoints: Endpoints, command: RecoverOrphanedTransactionCommand) {
const transactionId = command.getTransactionId();
const messagePB = command.getMessage()!;
const messageId = messagePB.getSystemProperties()!.getMessageId();
if (!this.#checker) {
this.logger.error('No transaction checker registered, ignore it, messageId=%s, transactionId=%s, endpoints=%s, clientId=%s',
messageId, transactionId, endpoints, this.clientId);
return;
}
let messageView: MessageView;
try {
messageView = new MessageView(messagePB);
} catch (err) {
this.logger.error('[Bug] Failed to decode message during orphaned transaction message recovery, messageId=%s, transactionId=%s, endpoints=%s, clientId=%s, error=%s',
messageId, transactionId, endpoints, this.clientId, err);
return;
}
try {
const resolution = await this.#checker.check(messageView);
if (resolution === null || resolution === TransactionResolution.TRANSACTION_RESOLUTION_UNSPECIFIED) {
return;
}
// Use SOURCE_SERVER_CHECK for transaction recovery
await this.endTransaction(endpoints, messageView, messageId, transactionId, resolution,
TransactionSource.SOURCE_SERVER_CHECK);
this.logger.info('Recover orphaned transaction message success, transactionId=%s, resolution=%s, messageId=%s, clientId=%s',
transactionId, resolution, messageId, this.clientId);
} catch (err) {
this.logger.error('Exception raised while checking the transaction, messageId=%s, transactionId=%s, endpoints=%s, clientId=%s, error=%s',
messageId, transactionId, endpoints, this.clientId, err);
return;
}
}
protected getSettings(): Settings {
return this.#publishingSettings;
}
/**
* Get the client type.
*
* @return The client type identifier for producer
*/
protected getClientType(): ClientType {
return ClientType.PRODUCER;
}
protected wrapHeartbeatRequest(): HeartbeatRequest {
return new HeartbeatRequest()
.setClientType(this.getClientType());
}
protected wrapNotifyClientTerminationRequest(): NotifyClientTerminationRequest {
return new NotifyClientTerminationRequest();
}
async send(message: MessageOptions, transaction?: Transaction) {
if (!transaction) {
const sendReceipts = await this.#send([ message ], false);
return sendReceipts[0];
}
// Send transactional message
try {
const publishingMessage = transaction.tryAddMessage(message);
const sendReceipts = await this.#send([ message ], true);
const sendReceipt = sendReceipts[0];
transaction.tryAddReceipt(publishingMessage, sendReceipt);
return sendReceipt;
} catch (err) {
this.logger.error('Failed to send transactional message, clientId=%s, error=%s', this.clientId, err);
throw err;
}
}
async #send(messages: MessageOptions[], txEnabled: boolean) {
// Check producer status before message publishing
if (!this.isRunning()) {
this.logger.error('Unable to send message because producer is not running, clientId=%s', this.clientId);
throw new Error('Producer is not running now');
}
const pubMessages: PublishingMessage[] = [];
const topics = new Set<string>();
for (const message of messages) {
pubMessages.push(new PublishingMessage(message, this.#publishingSettings, txEnabled));
topics.add(message.topic);
}
if (topics.size > 1) {
throw new TypeError(`Messages to send have different topics=${JSON.stringify(Array.from(topics))}`);
}
const topic = pubMessages[0].topic;
const messageType = pubMessages[0].messageType;
const messageGroup = pubMessages[0].messageGroup;
const liteTopic = pubMessages[0].liteTopic;
const messageTypes = new Set(pubMessages.map(m => m.messageType));
if (messageTypes.size > 1) {
throw new TypeError(`Messages to send have different types=${JSON.stringify(Array.from(messageTypes))}`);
}
// Log Lite Topic message sending
if (liteTopic) {
this.logger.debug?.('Sending Lite Topic message, topic=%s, liteTopic=%s, messageType=%s, clientId=%s',
topic, liteTopic, MessageType[messageType], this.clientId);
}
// Message group must be same if message type is FIFO, or no need to proceed.
if (messageType === MessageType.FIFO) {
const messageGroups = new Set(pubMessages.map(m => m.messageGroup!));
if (messageGroups.size > 1) {
throw new TypeError(`FIFO messages to send have message groups, messageGroups=${JSON.stringify(Array.from(messageGroups))}`);
}
}
// Get publishing topic route.
const loadBalancer = await this.#getPublishingLoadBalancer(topic);
// Prepare the candidate message queue(s) for retry-sending in advance.
const candidates = messageGroup ? [ loadBalancer.takeMessageQueueByMessageGroup(messageGroup) ] :
this.#takeMessageQueues(loadBalancer);
return await this.#send0(topic, messageType, candidates, pubMessages, 1);
}
#wrapSendMessageRequest(pubMessages: PublishingMessage[], mq: MessageQueue) {
const request = new SendMessageRequest();
for (const pubMessage of pubMessages) {
if (this.namespace) {
request.addMessages(pubMessage.toProtobuf(this.namespace, mq));
} else {
request.addMessages(pubMessage.toProtobuf('', mq));
}
}
return request;
}
/**
* Isolate specified Endpoints
*/
#isolate(endpoints: Endpoints) {
this.isolated.set(endpoints.facade, endpoints);
}
async #send0(topic: string, messageType: MessageType, candidates: MessageQueue[],
messages: PublishingMessage[], attempt: number): Promise<SendReceipt[]> {
// Calculate the current message queue.
const index = (attempt - 1) % candidates.length;
const mq = candidates[index];
const acceptMessageTypes = mq.acceptMessageTypesList;
if (this.#publishingSettings.isValidateMessageType() && !acceptMessageTypes.includes(messageType)) {
throw new TypeError('Current message type not match with ' +
'topic accept message types, topic=' + topic + ', actualMessageType=' + messageType + ', ' +
'acceptMessageTypes=' + JSON.stringify(acceptMessageTypes));
}
const endpoints = mq.broker.endpoints;
const maxAttempts = this.#getRetryPolicy().getMaxAttempts();
const request = this.#wrapSendMessageRequest(messages, mq);
let sendReceipts: SendReceipt[] = [];
try {
const response = await this.rpcClientManager.sendMessage(endpoints, request, this.requestTimeout);
sendReceipts = SendReceipt.processResponseInvocation(mq, response);
} catch (err) {
const messageIds = messages.map(m => m.messageId);
// Isolate endpoints because of sending failure
this.#isolate(endpoints);
if (attempt >= maxAttempts) {
// No more attempts
this.logger.error('Failed to send message(s) finally, run out of attempt times, maxAttempts=%d, attempt=%d, topic=%s, messageId(s)=%s, endpoints=%s, clientId=%s, error=%s',
maxAttempts, attempt, topic, messageIds, endpoints, this.clientId, err);
throw err;
}
// No more attempts for transactional message
if (messageType === MessageType.TRANSACTION) {
this.logger.error('Failed to send transactional message finally, maxAttempts=%d, attempt=%d, topic=%s, messageId(s)=%s, endpoints=%s, clientId=%s, error=%s',
maxAttempts, attempt, topic, messageIds, endpoints, this.clientId, err);
throw err;
}
// Try next attempt
const nextAttempt = 1 + attempt;
// Retry immediately if the request is not throttled
if (!(err instanceof TooManyRequestsException)) {
this.logger.warn('Failed to send message, would attempt to resend right now, maxAttempts=%d, attempt=%d, topic=%s, messageId(s)=%s, endpoints=%s, clientId=%s, error=%s',
maxAttempts, attempt, topic, messageIds, endpoints, this.clientId, err);
return this.#send0(topic, messageType, candidates, messages, nextAttempt);
}
const delay = this.#getRetryPolicy().getNextAttemptDelay(nextAttempt);
this.logger.warn('Failed to send message due to too many requests, would attempt to resend after %dms, maxAttempts=%d, attempt=%d, topic=%s, messageId(s)=%s, endpoints=%s, clientId=%s, error=%s',
delay, maxAttempts, attempt, topic, messageIds, endpoints, this.clientId, err);
await setTimeout(delay);
return this.#send0(topic, messageType, candidates, messages, nextAttempt);
}
// Resend message(s) successfully
if (attempt > 1) {
const messageIds = sendReceipts.map(r => r.messageId);
this.logger.info('Resend message successfully, topic=%s, messageId(s)=%s, maxAttempts=%d, attempt=%d, endpoints=%s, clientId=%s',
topic, messageIds, maxAttempts, attempt, endpoints, this.clientId);
}
// Log Lite Topic message sent successfully
if (sendReceipts.length > 0 && messages[0].liteTopic) {
this.logger.debug?.('Lite Topic message sent successfully, topic=%s, liteTopic=%s, messageId=%s, clientId=%s',
topic, messages[0].liteTopic, sendReceipts[0].messageId, this.clientId);
}
// Send message(s) successfully on first attempt, return directly
return sendReceipts;
}
async #getPublishingLoadBalancer(topic: string) {
let loadBalancer = this.#publishingRouteDataCache.get(topic);
if (!loadBalancer) {
const topicRouteData = await this.getRouteData(topic);
loadBalancer = this.#updatePublishingLoadBalancer(topic, topicRouteData);
}
return loadBalancer;
}
#updatePublishingLoadBalancer(topic: string, topicRouteData: TopicRouteData) {
let loadBalancer = this.#publishingRouteDataCache.get(topic);
if (loadBalancer) {
loadBalancer = loadBalancer.update(topicRouteData);
} else {
loadBalancer = new PublishingLoadBalancer(topicRouteData);
}
this.#publishingRouteDataCache.set(topic, loadBalancer);
return loadBalancer;
}
/**
* Take message queue(s) from route for message publishing.
*/
#takeMessageQueues(loadBalancer: PublishingLoadBalancer) {
return loadBalancer.takeMessageQueues(this.isolated, this.#getRetryPolicy().getMaxAttempts());
}
#getRetryPolicy() {
return this.#publishingSettings.getRetryPolicy()!;
}
/**
* Recalls a scheduled/delayed message based on the topic and recall handle.
* This operation requires server support and can only be performed before the message is delivered.
*
* @param topic - The topic associated with the scheduled message to be canceled.
* @param recallHandle - A unique handle to identify the message to recall (obtained from SendReceipt).
* @return Promise resolving to RecallReceipt containing the recalled message ID.
* @throws Error if producer is not running or recall handle is invalid.
*/
async recallMessage(topic: string, recallHandle: string): Promise<RecallReceipt> {
if (!this.isRunning()) {
this.logger.error('Unable to recall message because producer is not running, clientId=%s', this.clientId);
throw new Error('Producer is not running now');
}
// Validate topic
if (!topic || typeof topic !== 'string' || topic.trim().length === 0) {
this.logger.error('Topic is invalid for recall message, clientId=%s', this.clientId);
throw new Error('Topic is invalid');
}
if (!recallHandle) {
this.logger.error('Recall handle is invalid, clientId=%s', this.clientId);
throw new Error('Recall handle is invalid');
}
this.logger.info('Begin to recall message, topic=%s, recallHandle=%s, clientId=%s',
topic, recallHandle, this.clientId);
const request = new RecallMessageRequest()
.setTopic(createResource(topic).setResourceNamespace(this.namespace))
.setRecallHandle(recallHandle);
const response = await this.rpcClientManager.recallMessage(this.endpoints, request, this.requestTimeout);
const status = response.getStatus();
if (!status) {
throw new Error('Recall message response status is null');
}
// Check status code
const statusCode = status.getCode();
if (statusCode !== Code.OK) {
const errorMessage = status.getMessage() || 'Unknown error';
throw new Error(`Failed to recall message: ${errorMessage} (code: ${statusCode})`);
}
const messageId = response.getMessageId();
const receipt = new RecallReceipt(messageId);
this.logger.info('Recall message successfully, topic=%s, recallHandle=%s, messageId=%s, clientId=%s',
topic, recallHandle, messageId, this.clientId);
return receipt;
}
}