@zaplify/gcp-pubsub
Version:
Cloud Pub/Sub Client Library for Node.js
493 lines • 18.4 kB
JavaScript
"use strict";
/*!
* Copyright 2018 Google Inc. All Rights Reserved.
*
* Licensed 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.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ModAckQueue = exports.AckQueue = exports.MessageQueue = exports.BatchError = void 0;
const google_gax_1 = require("google-gax");
const defer = require("p-defer");
const ack_metadata_1 = require("./ack-metadata");
const exponential_retry_1 = require("./exponential-retry");
const subscriber_1 = require("./subscriber");
const temporal_1 = require("./temporal");
const util_1 = require("./util");
const debug_1 = require("./debug");
/**
* Error class used to signal a batch failure.
*
* Now that we have exactly-once delivery subscriptions, we'll only
* throw one of these if there was an unknown error.
*
* @class
*
* @param {string} message The error message.
* @param {GoogleError} err The grpc error.
*/
class BatchError extends debug_1.DebugMessage {
constructor(err, ackIds, rpc) {
super(`Failed to "${rpc}" for ${ackIds.length} message(s). Reason: ${process.env.DEBUG_GRPC ? err.stack : err.message}`, err);
this.ackIds = ackIds;
this.code = err.code;
this.details = err.message;
}
}
exports.BatchError = BatchError;
/**
* @typedef {object} BatchOptions
* @property {object} [callOptions] Request configuration option, outlined
* here: {@link https://googleapis.github.io/gax-nodejs/interfaces/CallOptions.html}.
* @property {number} [maxMessages=3000] Maximum number of messages allowed in
* each batch sent.
* @property {number} [maxMilliseconds=100] Maximum duration to wait before
* sending a batch. Batches can be sent earlier if the maxMessages option
* is met before the configured duration has passed.
*/
/**
* Class for buffering ack/modAck requests.
*
* @private
* @class
*
* @param {Subscriber} sub The subscriber we're queueing requests for.
* @param {BatchOptions} options Batching options.
*/
class MessageQueue {
constructor(sub, options = {}) {
this._closed = false;
this.numPendingRequests = 0;
this.numInFlightRequests = 0;
this.numInRetryRequests = 0;
this._requests = [];
this._subscriber = sub;
this._retrier = new exponential_retry_1.ExponentialRetry(temporal_1.Duration.from({ seconds: 1 }), temporal_1.Duration.from({ seconds: 64 }));
this.setOptions(options);
}
/**
* Shuts down this message queue gracefully. Any acks/modAcks pending in
* the queue or waiting for retry will be removed. If exactly-once delivery
* is enabled on the subscription, we'll send permanent failures to
* anyone waiting on completions; otherwise we'll send successes.
*
* If a flush is desired first, do it before calling close().
*
* @private
*/
close() {
let requests = this._requests;
this._requests = [];
this.numInFlightRequests = this.numPendingRequests = 0;
requests = requests.concat(this._retrier.close());
const isExactlyOnceDelivery = this._subscriber.isExactlyOnceDelivery;
requests.forEach(r => {
if (r.responsePromise) {
if (isExactlyOnceDelivery) {
r.responsePromise.reject(new subscriber_1.AckError(subscriber_1.AckResponses.Invalid, 'Subscriber closed'));
}
else {
r.responsePromise.resolve();
}
}
});
this._closed = true;
}
/**
* Gets the default buffer time in ms.
*
* @returns {number}
* @private
*/
get maxMilliseconds() {
return this._options.maxMilliseconds;
}
/**
* Adds a message to the queue.
*
* @param {Message} message The message to add.
* @param {number} [deadline] The deadline.
* @private
*/
add({ ackId }, deadline) {
if (this._closed) {
if (this._subscriber.isExactlyOnceDelivery) {
throw new subscriber_1.AckError(subscriber_1.AckResponses.Invalid, 'Subscriber closed');
}
else {
return Promise.resolve();
}
}
const { maxMessages, maxMilliseconds } = this._options;
const responsePromise = defer();
this._requests.push({
ackId,
deadline,
responsePromise,
retryCount: 0,
});
this.numPendingRequests++;
this.numInFlightRequests++;
if (this._requests.length >= maxMessages) {
this.flush();
}
else if (!this._timer) {
this._timer = setTimeout(() => this.flush(), maxMilliseconds);
}
return responsePromise.promise;
}
/**
* Retry handler for acks/modacks that have transient failures. Unless
* it's passed the final deadline, we will just re-queue it for sending.
*
* @private
*/
handleRetry(message, totalTime) {
var _a;
// Has it been too long?
if (totalTime.totalOf('minute') >= 10 || this.shouldFailEarly(message)) {
(_a = message.responsePromise) === null || _a === void 0 ? void 0 : _a.reject(new subscriber_1.AckError(subscriber_1.AckResponses.Invalid, 'Retried for too long'));
return;
}
// Just throw it in for another round of processing on the next batch.
this._requests.push(message);
this.numPendingRequests++;
this.numInFlightRequests++;
this.numInRetryRequests--;
// Make sure we actually do have another batch scheduled.
if (!this._timer) {
this._timer = setTimeout(() => this.flush(), this._options.maxMilliseconds);
}
}
/**
* This hook lets a subclass tell the retry handler to go ahead and fail early.
*
* @private
*/
shouldFailEarly(message) {
message;
return false;
}
/**
* Sends a batch of messages.
* @private
*/
async flush() {
if (this._timer) {
clearTimeout(this._timer);
delete this._timer;
}
const batch = this._requests;
const batchSize = batch.length;
const deferred = this._onFlush;
this._requests = [];
this.numPendingRequests -= batchSize;
delete this._onFlush;
try {
const toRetry = await this._sendBatch(batch);
// We'll get back anything that needs a retry for transient errors.
for (const m of toRetry) {
this.numInRetryRequests++;
m.retryCount++;
this._retrier.retryLater(m, this.handleRetry.bind(this));
}
}
catch (e) {
// These queues are used for ack and modAck messages, which should
// never surface an error to the user level. However, we'll emit
// them onto this debug channel in case debug info is needed.
const err = e;
const debugMsg = new debug_1.DebugMessage(err.message, err);
this._subscriber.emit('debug', debugMsg);
}
this.numInFlightRequests -= batchSize;
if (deferred) {
deferred.resolve();
}
if (this.numInFlightRequests <= 0 &&
this.numInRetryRequests <= 0 &&
this._onDrain) {
this._onDrain.resolve();
delete this._onDrain;
}
}
/**
* Returns a promise that resolves after the next flush occurs.
*
* @returns {Promise}
* @private
*/
onFlush() {
if (!this._onFlush) {
this._onFlush = defer();
}
return this._onFlush.promise;
}
/**
* Returns a promise that resolves when all in-flight messages have settled.
*/
onDrain() {
if (!this._onDrain) {
this._onDrain = defer();
}
return this._onDrain.promise;
}
/**
* Set the batching options.
*
* @param {BatchOptions} options Batching options.
* @private
*/
setOptions(options) {
const defaults = { maxMessages: 3000, maxMilliseconds: 100 };
this._options = Object.assign(defaults, options);
}
/**
* Succeed a whole batch of Acks/Modacks for an OK RPC response.
*
* @private
*/
handleAckSuccesses(batch) {
// Everyone gets a resolve!
batch.forEach(({ responsePromise }) => {
responsePromise === null || responsePromise === void 0 ? void 0 : responsePromise.resolve();
});
}
/**
* If we get an RPC failure of any kind, this will take care of deciding
* what to do for each related ack/modAck. Successful ones will have their
* Promises resolved, permanent errors will have their Promises rejected,
* and transients will be returned for retry.
*
* Note that this is only used for subscriptions with exactly-once
* delivery enabled, so _sendBatch() in the classes below take care of
* resolving errors to success; they don't make it here.
*
* @private
*/
handleAckFailures(operation, batch, rpcError) {
const toSucceed = [];
const toRetry = [];
const toError = new Map([
[subscriber_1.AckResponses.PermissionDenied, []],
[subscriber_1.AckResponses.FailedPrecondition, []],
[subscriber_1.AckResponses.Other, []],
]);
// Parse any error codes, both for the RPC call and the ErrorInfo.
const error = rpcError.code
? (0, ack_metadata_1.processAckRpcError)(rpcError.code)
: undefined;
const codes = (0, ack_metadata_1.processAckErrorInfo)(rpcError);
for (const m of batch) {
if (codes.has(m.ackId)) {
// This ack has an ErrorInfo entry, so use that to route it.
const code = codes.get(m.ackId);
if (code.transient) {
// Transient errors get retried.
toRetry.push(m);
}
else {
// It's a permanent error.
(0, util_1.addToBucket)(toError, code.response, m);
}
}
else if (error !== undefined) {
// This ack doesn't have an ErrorInfo entry, but we do have an RPC
// error, so use that to route it.
if (error.transient) {
toRetry.push(m);
}
else {
(0, util_1.addToBucket)(toError, error.response, m);
}
}
else {
// Looks like this one worked out.
toSucceed.push(m);
}
}
// To remain consistent with previous behaviour, we will push a debug
// stream message if an unknown error happens during ack.
const others = toError.get(subscriber_1.AckResponses.Other);
if (others === null || others === void 0 ? void 0 : others.length) {
const otherIds = others.map(e => e.ackId);
const debugMsg = new BatchError(rpcError, otherIds, operation);
this._subscriber.emit('debug', debugMsg);
}
// Take care of following up on all the Promises.
toSucceed.forEach(m => {
var _a;
(_a = m.responsePromise) === null || _a === void 0 ? void 0 : _a.resolve();
});
for (const e of toError.entries()) {
e[1].forEach(m => {
var _a;
const exc = new subscriber_1.AckError(e[0], rpcError.message);
(_a = m.responsePromise) === null || _a === void 0 ? void 0 : _a.reject(exc);
});
}
return {
toError,
toRetry,
};
}
/**
* Since we handle our own retries for ack/modAck calls when exactly-once
* delivery is enabled on a subscription, we conditionally need to disable
* the gax retries. This returns an appropriate CallOptions for the
* subclasses to pass down.
*
* @private
*/
getCallOptions() {
let callOptions = this._options.callOptions;
if (this._subscriber.isExactlyOnceDelivery) {
// If exactly-once-delivery is enabled, tell gax not to do retries for us.
callOptions = Object.assign({}, callOptions !== null && callOptions !== void 0 ? callOptions : {});
callOptions.retry = new google_gax_1.RetryOptions([], {
initialRetryDelayMillis: 0,
retryDelayMultiplier: 0,
maxRetryDelayMillis: 0,
});
}
return callOptions;
}
}
exports.MessageQueue = MessageQueue;
/**
* Queues up Acknowledge (ack) requests.
*
* @private
* @class
*/
class AckQueue extends MessageQueue {
/**
* Sends a batch of ack requests.
*
* @private
*
* @param {Array.<Array.<string|number>>} batch Array of ackIds and deadlines.
* @return {Promise}
*/
async _sendBatch(batch) {
const client = await this._subscriber.getClient();
const ackIds = batch.map(({ ackId }) => ackId);
const reqOpts = { subscription: this._subscriber.name, ackIds };
try {
await client.acknowledge(reqOpts, this.getCallOptions());
// It's okay if these pass through since they're successful anyway.
this.handleAckSuccesses(batch);
return [];
}
catch (e) {
// If exactly-once delivery isn't enabled, don't do error processing. We'll
// emulate previous behaviour by resolving all pending Promises with
// a success status, and then throwing a BatchError for debug logging.
if (!this._subscriber.isExactlyOnceDelivery) {
batch.forEach(m => {
var _a;
(_a = m.responsePromise) === null || _a === void 0 ? void 0 : _a.resolve();
});
throw new BatchError(e, ackIds, 'ack');
}
else {
const grpcError = e;
try {
const results = this.handleAckFailures('ack', batch, grpcError);
return results.toRetry;
}
catch (e) {
// This should only ever happen if there's a code failure.
const err = e;
this._subscriber.emit('debug', new debug_1.DebugMessage(err.message, err));
const exc = new subscriber_1.AckError(subscriber_1.AckResponses.Other, 'Code error');
batch.forEach(m => {
var _a;
(_a = m.responsePromise) === null || _a === void 0 ? void 0 : _a.reject(exc);
});
return [];
}
}
}
}
}
exports.AckQueue = AckQueue;
/**
* Queues up ModifyAckDeadline requests and sends them out in batches.
*
* @private
* @class
*/
class ModAckQueue extends MessageQueue {
/**
* Sends a batch of modAck requests. Each deadline requires its own request,
* so we have to group all the ackIds by deadline and send multiple requests.
*
* @private
*
* @param {Array.<Array.<string|number>>} batch Array of ackIds and deadlines.
* @return {Promise}
*/
async _sendBatch(batch) {
const client = await this._subscriber.getClient();
const subscription = this._subscriber.name;
const modAckTable = batch.reduce((table, message) => {
if (!table[message.deadline]) {
table[message.deadline] = [];
}
table[message.deadline].push(message);
return table;
}, {});
const callOptions = this.getCallOptions();
const modAckRequests = Object.keys(modAckTable).map(async (deadline) => {
const messages = modAckTable[deadline];
const ackIds = messages.map(m => m.ackId);
const ackDeadlineSeconds = Number(deadline);
const reqOpts = { subscription, ackIds, ackDeadlineSeconds };
try {
await client.modifyAckDeadline(reqOpts, callOptions);
// It's okay if these pass through since they're successful anyway.
this.handleAckSuccesses(messages);
return [];
}
catch (e) {
// If exactly-once delivery isn't enabled, don't do error processing. We'll
// emulate previous behaviour by resolving all pending Promises with
// a success status, and then throwing a BatchError for debug logging.
if (!this._subscriber.isExactlyOnceDelivery) {
batch.forEach(m => {
var _a;
(_a = m.responsePromise) === null || _a === void 0 ? void 0 : _a.resolve();
});
throw new BatchError(e, ackIds, 'modAck');
}
else {
const grpcError = e;
const newBatch = this.handleAckFailures('modAck', messages, grpcError);
return newBatch.toRetry;
}
}
});
// This catches the sub-failures and bubbles up anything we need to bubble.
const allNewBatches = await Promise.all(modAckRequests);
return allNewBatches.reduce((p, c) => [
...(p !== null && p !== void 0 ? p : []),
...c,
]);
}
// For modacks only, we'll stop retrying after 3 tries.
shouldFailEarly(message) {
return message.retryCount >= 3;
}
}
exports.ModAckQueue = ModAckQueue;
//# sourceMappingURL=message-queues.js.map