@azure/service-bus
Version:
Azure Service Bus SDK for JavaScript
389 lines • 19.8 kB
JavaScript
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.
import { receiverLogger as logger } from "../log";
import { ReceiverEvents, SessionEvents, } from "rhea-promise";
import { ServiceBusMessageImpl } from "../serviceBusMessage";
import { MessageReceiver } from "./messageReceiver";
import { throwErrorIfConnectionClosed } from "../util/errors";
import { checkAndRegisterWithAbortSignal } from "../util/utils";
import { receiveDrainTimeoutInMs } from "../util/constants";
import { toProcessingSpanOptions } from "../diagnostics/instrumentServiceBusMessage";
import { ServiceBusError, translateServiceBusError } from "../serviceBusError";
import { tracingClient } from "../diagnostics/tracing";
/**
* Describes the batching receiver where the user can receive a specified number of messages for
* a predefined time.
* @internal
*/
export class BatchingReceiver extends MessageReceiver {
/**
* Instantiate a new BatchingReceiver.
*
* @param identifier - name to identify this receiver.
* @param connectionContext - The client entity context.
* @param options - Options for how you'd like to connect.
*/
constructor(identifier, connectionContext, entityPath, options) {
super(identifier, connectionContext, entityPath, "batching", options);
this._batchingReceiverLite = new BatchingReceiverLite(connectionContext, entityPath, async (abortSignal) => {
let lastError;
const rcvrOptions = this._createReceiverOptions(false, {
onError: (context) => {
lastError = context?.receiver?.error;
},
onSessionError: (context) => {
lastError = context?.session?.error;
},
onClose: async () => {
/** Nothing to do here - the next call will just fail so they'll get an appropriate error from somewhere else. */
},
onSessionClose: async () => {
/** Nothing to do here - the next call will just fail so they'll get an appropriate error from somewhere else. */
},
onMessage: async () => {
/** Nothing to do here - we don't add credits initially so we don't need to worry about handling any messages.*/
},
});
await this._init(rcvrOptions, abortSignal);
if (lastError != null) {
throw lastError;
}
return this.link;
}, this.receiveMode, options.skipParsingBodyAsJson ?? false, options.skipConvertingDate ?? false);
}
get isReceivingMessages() {
return this._batchingReceiverLite.isReceivingMessages;
}
/**
* To be called when connection is disconnected to gracefully close ongoing receive request.
* @param connectionError - The connection error if any.
*/
async onDetached(connectionError) {
await this.closeLink();
if (connectionError == null) {
connectionError = new Error("Unknown error occurred on the AMQP connection while receiving messages.");
}
this._batchingReceiverLite.terminate(connectionError);
}
/**
* Receives a batch of messages from a ServiceBus Queue/Topic.
* @param maxMessageCount - The maximum number of messages to receive.
* In Peeklock mode, this number is capped at 2047 due to constraints of the underlying buffer.
* @param maxWaitTimeInMs - The total wait time in milliseconds until which the receiver will attempt to receive specified number of messages.
* @param maxTimeAfterFirstMessageInMs - The total amount of time to wait after the first message
* has been received. Defaults to 1 second.
* If this time elapses before the `maxMessageCount` is reached, then messages collected till then will be returned to the user.
* @returns A promise that resolves with an array of Message objects.
*/
async receive(maxMessageCount, maxWaitTimeInMs, maxTimeAfterFirstMessageInMs, options) {
throwErrorIfConnectionClosed(this._context);
try {
const messages = await this._batchingReceiverLite.receiveMessages({
maxMessageCount,
maxWaitTimeInMs,
maxTimeAfterFirstMessageInMs,
...options,
});
if (this._lockRenewer) {
for (const message of messages) {
this._lockRenewer.start(this, message, (_error) => {
// the auto lock renewer already logs this in a detailed way. So this hook is mainly here
// to potentially forward the error to the user (which we're not doing yet)
});
}
}
return messages;
}
catch (error) {
logger.logError(error, "[%s] Rejecting receiveMessages()", this.logPrefix);
throw error;
}
}
static create(clientId, context, entityPath, options) {
throwErrorIfConnectionClosed(context);
const bReceiver = new BatchingReceiver(clientId, context, entityPath, options);
context.messageReceivers[bReceiver.name] = bReceiver;
return bReceiver;
}
removeLinkFromContext() {
delete this._context.messageReceivers[this.name];
}
}
/**
* Gets a function that returns the smaller of the two timeouts,
* taking into account elapsed time from when getRemainingWaitTimeInMsFn
* was called.
*
* @param maxWaitTimeInMs - Maximum time to wait for the first message
* @param maxTimeAfterFirstMessageInMs - Maximum time to wait after the first message before completing the receive.
*
* @internal
*/
export function getRemainingWaitTimeInMsFn(maxWaitTimeInMs, maxTimeAfterFirstMessageInMs) {
const startTimeMs = Date.now();
return () => {
const remainingTimeMs = maxWaitTimeInMs - (Date.now() - startTimeMs);
if (remainingTimeMs < 0) {
return 0;
}
return Math.min(remainingTimeMs, maxTimeAfterFirstMessageInMs);
};
}
/**
* The internals of a batching receiver minus anything that would require us to hold onto a client entity context
* or a receiver on a permanent basis.
*
* Usable with both session and non-session receivers.
*
* @internal
*/
export class BatchingReceiverLite {
constructor(_connectionContext, entityPath, _getCurrentReceiver, _receiveMode, _skipParsingBodyAsJson, _skipConvertingDate) {
this._connectionContext = _connectionContext;
this.entityPath = entityPath;
this._getCurrentReceiver = _getCurrentReceiver;
this._receiveMode = _receiveMode;
// testing hook
this._drainTimeoutInMs = receiveDrainTimeoutInMs;
this._createServiceBusMessage = (context) => {
return new ServiceBusMessageImpl(context.message, context.delivery, true, this._receiveMode, _skipParsingBodyAsJson, _skipConvertingDate);
};
this._getRemainingWaitTimeInMsFn = (maxWaitTimeInMs, maxTimeAfterFirstMessageInMs) => getRemainingWaitTimeInMsFn(maxWaitTimeInMs, maxTimeAfterFirstMessageInMs);
this.isReceivingMessages = false;
}
/**
* Receives a set of messages,
*
* @internal
* @hidden
*/
async receiveMessages(args) {
try {
this.isReceivingMessages = true;
const receiver = await this._getCurrentReceiver(args.abortSignal);
if (receiver == null) {
// (was somehow closed in between the init() and the return)
throw new ServiceBusError("Link closed before receiving messages.", "GeneralError");
}
const messages = await new Promise((resolve, reject) => this._receiveMessagesImpl(receiver, args, resolve, reject));
return tracingClient.withSpan("BatchingReceiverLite.process", args, () => messages, toProcessingSpanOptions(messages, this, this._connectionContext.config, "process"));
}
finally {
this._closeHandler = undefined;
this.isReceivingMessages = false;
}
}
/**
* Closes the receiver (optionally with an error), cancelling any current operations.
*
* @param connectionError - An optional error (rhea doesn't always deliver one for certain disconnection events)
*/
terminate(connectionError) {
if (this._closeHandler) {
this._closeHandler(connectionError);
this._closeHandler = undefined;
}
}
async tryDrainReceiver(receiver, loggingPrefix, remainingWaitTimeInMs, abortSignal) {
if (!receiver.isOpen() || receiver.credit <= 0) {
return;
}
let drainTimedout = false;
let drainTimer;
const timeToWaitInMs = Math.max(this._drainTimeoutInMs, remainingWaitTimeInMs);
const drainPromise = new Promise((resolve) => {
function drainListener() {
logger.verbose(`${loggingPrefix} Receiver has been drained.`);
clearTimeout(drainTimer);
resolve();
}
function removeListeners() {
abortSignal?.removeEventListener("abort", onAbort);
receiver.removeListener(ReceiverEvents.receiverDrained, drainListener);
}
function onAbort() {
removeListeners();
clearTimeout(drainTimer);
resolve();
}
drainTimer = setTimeout(() => {
drainTimedout = true;
removeListeners();
resolve();
}, timeToWaitInMs);
receiver.once(ReceiverEvents.receiverDrained, drainListener);
abortSignal?.addEventListener("abort", onAbort);
});
receiver.drainCredit();
logger.verbose(`${loggingPrefix} Draining leftover credits(${receiver.credit}), waiting for event_drained event, or timing out after ${timeToWaitInMs} milliseconds...`);
await drainPromise;
if (drainTimedout) {
logger.warning(`${loggingPrefix} Time out after ${timeToWaitInMs} milliseconds when draining credits. Closing receiver...`);
// Close the receiver link since we have not received the receiver drain event
// to prevent out-of-sync state between local and remote
await receiver.close();
}
// Turn off draining.
receiver.drain = false;
}
_receiveMessagesImpl(receiver, args, origResolve, origReject) {
const getRemainingWaitTimeInMs = this._getRemainingWaitTimeInMsFn(args.maxWaitTimeInMs, args.maxTimeAfterFirstMessageInMs);
const brokeredMessages = [];
const loggingPrefix = `[${receiver.connection.id}|r:${receiver.name}]`;
let totalWaitTimer;
// eslint-disable-next-line prefer-const
let cleanupBeforeResolveOrReject;
const rejectAfterCleanup = (err) => {
cleanupBeforeResolveOrReject();
origReject(err);
};
const resolveImmediately = (result) => {
cleanupBeforeResolveOrReject();
origResolve(result);
};
const resolveAfterPendingMessageCallbacks = (result) => {
// NOTE: through rhea-promise, most of our event handlers are made asynchronous by calling setTimeout(emit).
// However, a small set (*error and drain) execute immediately. This can lead to a situation where the logical
// ordering of events is correct but the execution order is incorrect because the events are not all getting
// put into the task queue the same way.
// setTimeout() ensures that we resolve _after_ any already-queued onMessage handlers that may
// be waiting in the task queue.
setTimeout(() => {
cleanupBeforeResolveOrReject();
origResolve(result);
});
};
const onError = (context) => {
const eventType = context.session?.error != null ? "session_error" : "receiver_error";
let error = context.session?.error || context.receiver?.error;
if (error) {
error = translateServiceBusError(error);
logger.logError(error, `${loggingPrefix} '${eventType}' event occurred. Received an error`);
}
else {
error = new ServiceBusError("An error occurred while receiving messages.", "GeneralError");
}
rejectAfterCleanup(error);
};
this._closeHandler = (error) => {
if (
// no error, just closing. Go ahead and return what we have.
error == null ||
// Return the collected messages if in ReceiveAndDelete mode because otherwise they are lost forever
(this._receiveMode === "receiveAndDelete" && brokeredMessages.length)) {
logger.verbose(`${loggingPrefix} Closing. Resolving with ${brokeredMessages.length} messages.`);
return resolveAfterPendingMessageCallbacks(brokeredMessages);
}
rejectAfterCleanup(translateServiceBusError(error));
};
let abortSignalCleanupFunction = undefined;
// Final action to be performed after
// - maxMessageCount is reached or
// - maxWaitTime is passed or
// - newMessageWaitTimeoutInSeconds is passed since the last message was received
this._finalAction = async () => {
if (receiver.drain) {
// If a drain is already in process then we should let it complete. Some messages might still be in flight, but they will
// arrive before the drain completes.
logger.verbose(`${loggingPrefix} Already draining.`);
return;
}
const remainingWaitTimeInMs = getRemainingWaitTimeInMs();
await this.tryDrainReceiver(receiver, loggingPrefix, remainingWaitTimeInMs, args.abortSignal);
logger.verbose(`${loggingPrefix} Resolving receiveMessages() with ${brokeredMessages.length} messages.`);
resolveImmediately(brokeredMessages);
};
// Action to be performed on the "message" event.
const onReceiveMessage = async (context) => {
// TODO: this appears to be aggravating a bug that we need to look into more deeply.
// The same timeout+drain sequence should work fine for receiveAndDelete but it appears
// to cause problems.
if (this._receiveMode === "peekLock") {
if (brokeredMessages.length === 0) {
// We'll now remove the old timer (which was the overall `maxWaitTimeMs` timer)
// and replace it with another timer that is a (probably) much shorter interval.
//
// This allows the user to get access to received messages earlier and also gives us
// a chance to have fewer messages internally that could get lost if the user's
// app crashes.
if (totalWaitTimer)
clearTimeout(totalWaitTimer);
const remainingWaitTimeInMs = getRemainingWaitTimeInMs();
totalWaitTimer = setTimeout(() => {
logger.verbose(`${loggingPrefix} Batching, waited for ${remainingWaitTimeInMs} milliseconds after receiving the first message.`);
this._finalAction();
}, remainingWaitTimeInMs);
}
}
try {
const data = this._createServiceBusMessage(context);
brokeredMessages.push(data);
// NOTE: we used to actually "lose" any extra messages. At this point I've fixed the areas that were causing us to receive
// extra messages but if this bug arises in some other way it's better to return the message than it would be to let it be
// silently dropped on the floor.
if (brokeredMessages.length > args.maxMessageCount) {
logger.warning(`More messages arrived than expected: ${args.maxMessageCount} vs ${brokeredMessages.length}`);
}
}
catch (err) {
const errObj = err instanceof Error ? err : new Error(JSON.stringify(err));
logger.logError(err, `${loggingPrefix} Received an error while converting AmqpMessage to ServiceBusMessage`);
rejectAfterCleanup(errObj);
}
if (brokeredMessages.length >= args.maxMessageCount) {
this._finalAction();
}
};
const onClose = async (context) => {
const type = context.session?.error != null ? "session_closed" : "receiver_closed";
const error = context.session?.error || context.receiver?.error;
if (error) {
logger.logError(error, `${loggingPrefix} '${type}' event occurred. The associated error`);
}
};
cleanupBeforeResolveOrReject = () => {
if (receiver != null) {
receiver.removeListener(ReceiverEvents.receiverError, onError);
receiver.removeListener(ReceiverEvents.message, onReceiveMessage);
receiver.session.removeListener(SessionEvents.sessionError, onError);
receiver.removeListener(ReceiverEvents.receiverClose, onClose);
receiver.session.removeListener(SessionEvents.sessionClose, onClose);
}
if (totalWaitTimer) {
clearTimeout(totalWaitTimer);
}
if (abortSignalCleanupFunction) {
abortSignalCleanupFunction();
}
abortSignalCleanupFunction = undefined;
};
abortSignalCleanupFunction = checkAndRegisterWithAbortSignal((err) => {
if (receiver.drain) {
// If a drain is already in process and we cancel, the link state may be out of sync
// with remote. Reset the link so that we will have fresh start.
receiver.close();
}
rejectAfterCleanup(err);
}, args.abortSignal);
// By adding credit here, we let the service know that at max we can handle `maxMessageCount`
// number of messages concurrently. We will return the user an array of messages that can
// be of size upto maxMessageCount. Then the user needs to accordingly dispose
// (complete/abandon/defer/deadletter) the messages from the array.
const creditToAdd = args.maxMessageCount - receiver.credit;
logger.verbose(`${loggingPrefix} Ensure enough credit for receiving ${args.maxMessageCount} messages. Current: ${receiver.credit}. To add: ${creditToAdd}.`);
if (creditToAdd > 0) {
receiver.addCredit(creditToAdd);
}
logger.verbose(`${loggingPrefix} Setting the wait timer for ${args.maxWaitTimeInMs} milliseconds.`);
totalWaitTimer = setTimeout(() => {
logger.verbose(`${loggingPrefix} Batching, waited for max wait time ${args.maxWaitTimeInMs} milliseconds.`);
this._finalAction();
}, args.maxWaitTimeInMs);
receiver.on(ReceiverEvents.message, onReceiveMessage);
receiver.on(ReceiverEvents.receiverError, onError);
receiver.on(ReceiverEvents.receiverClose, onClose);
receiver.session.on(SessionEvents.sessionError, onError);
receiver.session.on(SessionEvents.sessionClose, onClose);
}
}
//# sourceMappingURL=batchingReceiver.js.map