UNPKG

@google-cloud/pubsub

Version:
820 lines (737 loc) 23.9 kB
/*! * Copyright 2014 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. */ /*! * @module pubsub/subscription */ 'use strict'; var arrify = require('arrify'); var common = require('@google-cloud/common'); var commonGrpc = require('@google-cloud/common-grpc'); var events = require('events'); var is = require('is'); var modelo = require('modelo'); var prop = require('propprop'); var uuid = require('uuid'); /** * @type {module:pubsub/iam} * @private */ var IAM = require('./iam.js'); /** * @const {number} - The amount of time a subscription pull HTTP connection to * Pub/Sub stays open. * @private */ var PUBSUB_API_TIMEOUT = 90000; /*! Developer Documentation * * @param {module:pubsub} pubsub - PubSub object. * @param {object} options - Configuration object. * @param {boolean} options.autoAck - Automatically acknowledge the message once * it's pulled. (default: false) * @param {string} options.encoding - When pulling for messages, this type is * used when converting a message's data to a string. (default: 'utf-8') * @param {number} options.interval - Interval in milliseconds to check for new * messages. (default: 10) * @param {string} options.name - Name of the subscription. * @param {number} options.maxInProgress - Maximum messages to consume * simultaneously. * @param {number} options.timeout - Set a maximum amount of time in * milliseconds on an HTTP request to pull new messages to wait for a * response before the connection is broken. (default: 90000) */ /** * A Subscription object will give you access to your Cloud Pub/Sub * subscription. * * Subscriptions are sometimes retrieved when using various methods: * * - {module:pubsub#getSubscriptions} * - {module:pubsub/topic#getSubscriptions} * - {module:pubsub/topic#subscribe} * * Subscription objects may be created directly with: * * - {module:pubsub/topic#subscription} * * All Subscription objects are instances of an * [EventEmitter](http://nodejs.org/api/events.html). The subscription will pull * for messages automatically as long as there is at least one listener assigned * for the `message` event. * * @alias module:pubsub/subscription * @constructor * * @example * //- * // From {module:pubsub#getSubscriptions}: * //- * pubsub.getSubscriptions(function(err, subscriptions) { * // `subscriptions` is an array of Subscription objects. * }); * * //- * // From {module:pubsub/topic#getSubscriptions}: * //- * var topic = pubsub.topic('my-topic'); * topic.getSubscriptions(function(err, subscriptions) { * // `subscriptions` is an array of Subscription objects. * }); * * //- * // From {module:pubsub/topic#subscribe}: * //- * var topic = pubsub.topic('my-topic'); * topic.subscribe('new-subscription', function(err, subscription) { * // `subscription` is a Subscription object. * }); * * //- * // From {module:pubsub/topic#subscription}: * //- * var topic = pubsub.topic('my-topic'); * var subscription = topic.subscription('my-subscription'); * // `subscription` is a Subscription object. * * //- * // Once you have obtained a subscription object, you may begin to register * // listeners. This will automatically trigger pulling for messages. * //- * * // Register an error handler. * subscription.on('error', function(err) {}); * * // Register a listener for `message` events. * function onMessage(message) { * // Called every time a message is received. * * // message.id = ID of the message. * // message.ackId = ID used to acknowledge the message receival. * // message.data = Contents of the message. * // message.attributes = Attributes of the message. * // message.timestamp = Timestamp when Pub/Sub received the message. * * // Ack the message: * // message.ack(callback); * * // Skip the message. This is useful with `maxInProgress` option when * // creating your subscription. This doesn't ack the message, but allows * // more messages to be retrieved if your limit was hit. * // message.skip(); * } * subscription.on('message', onMessage); * * // Remove the listener from receiving `message` events. * subscription.removeListener('message', onMessage); */ function Subscription(pubsub, options) { var name = options.name || Subscription.generateName_(); this.name = Subscription.formatName_(pubsub.projectId, name); var methods = { /** * Check if the subscription exists. * * @param {function} callback - The callback function. * @param {?error} callback.err - An error returned while making this * request. * @param {boolean} callback.exists - Whether the subscription exists or * not. * * @example * subscription.exists(function(err, exists) {}); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.exists().then(function(data) { * var exists = data[0]; * }); */ exists: true, /** * Get a subscription if it exists. * * You may optionally use this to "get or create" an object by providing an * object with `autoCreate` set to `true`. Any extra configuration that is * normally required for the `create` method must be contained within this * object as well. * * **`autoCreate` is only available if you accessed this object * through {module:pubsub/topic#subscription}.** * * @param {options=} options - Configuration object. * @param {boolean} options.autoCreate - Automatically create the object if * it does not exist. Default: `false` * * @example * subscription.get(function(err, subscription, apiResponse) { * // `subscription.metadata` has been populated. * }); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.get().then(function(data) { * var subscription = data[0]; * var apiResponse = data[1]; * }); */ get: true, /** * Get the metadata for the subscription. * * @resource [Subscriptions: get API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/get} * * @param {function} callback - The callback function. * @param {?error} callback.err - An error returned while making this * request. * @param {?object} callback.metadata - Metadata of the subscription from * the API. * @param {object} callback.apiResponse - Raw API response. * * @example * subscription.getMetadata(function(err, metadata, apiResponse) {}); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.getMetadata().then(function(data) { * var metadata = data[0]; * var apiResponse = data[1]; * }); */ getMetadata: { protoOpts: { service: 'Subscriber', method: 'getSubscription' }, reqOpts: { subscription: this.name } } }; var config = { parent: pubsub, id: this.name, methods: methods }; if (options.topic) { // Only a subscription with knowledge of its topic can be created. config.createMethod = pubsub.subscribe.bind(pubsub, options.topic); delete options.topic; /** * Create a subscription. * * **This is only available if you accessed this object through * {module:pubsub/topic#subscription}.** * * @param {object} config - See {module:pubsub#subscribe}. * * @example * subscription.create(function(err, subscription, apiResponse) { * if (!err) { * // The subscription was created successfully. * } * }); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.create().then(function(data) { * var subscription = data[0]; * var apiResponse = data[1]; * }); */ config.methods.create = true; } commonGrpc.ServiceObject.call(this, config); events.EventEmitter.call(this); this.autoAck = is.boolean(options.autoAck) ? options.autoAck : false; this.closed = true; this.encoding = options.encoding || 'utf-8'; this.inProgressAckIds = {}; this.interval = is.number(options.interval) ? options.interval : 10; this.maxInProgress = is.number(options.maxInProgress) ? options.maxInProgress : Infinity; this.messageListeners = 0; this.paused = false; if (is.number(options.timeout)) { this.timeout = options.timeout; } else { // The default timeout used in google-cloud-node is 60s, but a pull request // times out around 90 seconds. Allow an extra couple of seconds to give the // API a chance to respond on its own before terminating the connection. this.timeout = PUBSUB_API_TIMEOUT + 2000; } /** * [IAM (Identity and Access Management)](https://cloud.google.com/pubsub/access_control) * allows you to set permissions on individual resources and offers a wider * range of roles: editor, owner, publisher, subscriber, and viewer. This * gives you greater flexibility and allows you to set more fine-grained * access control. * * *The IAM access control features described in this document are Beta, * including the API methods to get and set IAM policies, and to test IAM * permissions. Cloud Pub/Sub's use of IAM features is not covered by * any SLA or deprecation policy, and may be subject to backward-incompatible * changes.* * * @mixes module:pubsub/iam * * @resource [Access Control Overview]{@link https://cloud.google.com/pubsub/access_control} * @resource [What is Cloud IAM?]{@link https://cloud.google.com/iam/} * * @example * //- * // Get the IAM policy for your subscription. * //- * subscription.iam.getPolicy(function(err, policy) { * console.log(policy); * }); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.iam.getPolicy().then(function(data) { * var policy = data[0]; * var apiResponse = data[1]; * }); */ this.iam = new IAM(pubsub, this.name); this.listenForEvents_(); } modelo.inherits(Subscription, commonGrpc.ServiceObject, events.EventEmitter); /** * Simplify a message from an API response to have five properties: `id`, * `ackId`, `data`, `attributes`, and `timestamp`. `data` is always converted to * a string. * * @private */ Subscription.formatMessage_ = function(msg, encoding) { var innerMessage = msg.message; var message = { ackId: msg.ackId }; if (innerMessage) { message.id = innerMessage.messageId; if (innerMessage.data) { message.data = new Buffer(innerMessage.data, 'base64').toString(encoding); try { message.data = JSON.parse(message.data); } catch(e) {} } if (innerMessage.attributes) { message.attributes = innerMessage.attributes; } if (innerMessage.publishTime) { var publishTime = innerMessage.publishTime; if (is.defined(publishTime.seconds) && is.defined(publishTime.nanos)) { var seconds = parseInt(publishTime.seconds, 10); var milliseconds = parseInt(publishTime.nanos, 10) / 1e6; message.timestamp = new Date(seconds * 1000 + milliseconds); } } } return message; }; /** * Format the name of a subscription. A subscription's full name is in the * format of projects/{projectId}/subscriptions/{subName}. * * @private */ Subscription.formatName_ = function(projectId, name) { // Simple check if the name is already formatted. if (name.indexOf('/') > -1) { return name; } return 'projects/' + projectId + '/subscriptions/' + name; }; /** * Generate a random name to use for a name-less subscription. * * @private */ Subscription.generateName_ = function() { return 'autogenerated-' + uuid.v4(); }; /** * Acknowledge to the backend that the message was retrieved. You must provide * either a single ackId or an array of ackIds. * * @resource [Subscriptions: acknowledge API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/acknowledge} * * @throws {Error} If at least one ackId is not provided. * * @param {string|string[]} ackIds - An ackId or array of ackIds. * @param {function=} callback - The callback function. * * @example * var ackId = 'ePHEESyhuE8e...'; * * subscription.ack(ackId, function(err, apiResponse) {}); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.ack(ackId).then(function(data) { * var apiResponse = data[0]; * }); */ Subscription.prototype.ack = function(ackIds, callback) { var self = this; ackIds = arrify(ackIds); if (ackIds.length === 0) { throw new Error([ 'At least one ID must be specified before it can be acknowledged.' ].join('')); } callback = callback || common.util.noop; var protoOpts = { service: 'Subscriber', method: 'acknowledge' }; var reqOpts = { subscription: this.name, ackIds: ackIds }; this.request(protoOpts, reqOpts, function(err, resp) { if (!err) { ackIds.forEach(function(ackId) { delete self.inProgressAckIds[ackId]; }); self.refreshPausedStatus_(); } callback(err, resp); }); }; /** * Add functionality on top of a message returned from the API, including the * ability to `ack` and `skip` the message. * * This also records the message as being "in progress". See * {module:subscription#refreshPausedStatus_}. * * @private * * @param {object} message - A message object. * @return {object} message - The original message after being decorated. * @param {function} message.ack - Ack the message. * @param {function} message.skip - Increate the number of available messages to * simultaneously receive. */ Subscription.prototype.decorateMessage_ = function(message) { var self = this; this.inProgressAckIds[message.ackId] = true; message.ack = self.ack.bind(self, message.ackId); message.skip = function() { delete self.inProgressAckIds[message.ackId]; self.refreshPausedStatus_(); }; return message; }; /** * Delete the subscription. Pull requests from the current subscription will be * errored once unsubscription is complete. * * @resource [Subscriptions: delete API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/delete} * * @param {function=} callback - The callback function. * @param {?error} callback.err - An error returned while making this * request. * @param {object} callback.apiResponse - Raw API response. * * @example * subscription.delete(function(err, apiResponse) {}); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.delete().then(function(data) { * var apiResponse = data[0]; * }); */ Subscription.prototype.delete = function(callback) { var self = this; callback = callback || common.util.noop; var protoOpts = { service: 'Subscriber', method: 'deleteSubscription' }; var reqOpts = { subscription: this.name }; this.request(protoOpts, reqOpts, function(err, resp) { if (err) { callback(err, resp); return; } self.closed = true; self.removeAllListeners(); callback(null, resp); }); }; /** * Pull messages from the subscribed topic. If messages were found, your * callback is executed with an array of message objects. * * Note that messages are pulled automatically once you register your first * event listener to the subscription, thus the call to `pull` is handled for * you. If you don't want to start pulling, simply don't register a * `subscription.on('message', function() {})` event handler. * * @todo Should not be racing with other pull. * * @resource [Subscriptions: pull API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/pull} * * @param {object=} options - Configuration object. * @param {number} options.maxResults - Limit the amount of messages pulled. * @param {boolean} options.returnImmediately - If set, the system will respond * immediately. Otherwise, wait until new messages are available. Returns if * timeout is reached. * @param {function} callback - The callback function. * * @example * //- * // Pull all available messages. * //- * subscription.pull(function(err, messages) { * // messages = [ * // { * // ackId: '', // ID used to acknowledge its receival. * // id: '', // Unique message ID. * // data: '', // Contents of the message. * // attributes: {} // Attributes of the message. * // * // Helper functions: * // ack(callback): // Ack the message. * // skip(): // Free up 1 slot on the sub's maxInProgress value. * // }, * // // ... * // ] * }); * * //- * // Pull a single message. * //- * var opts = { * maxResults: 1 * }; * * subscription.pull(opts, function(err, messages, apiResponse) {}); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.pull(opts).then(function(data) { * var messages = data[0]; * var apiResponse = data[1]; * }); */ Subscription.prototype.pull = function(options, callback) { var self = this; var MAX_EVENTS_LIMIT = 1000; if (!callback) { callback = options; options = {}; } if (!is.number(options.maxResults)) { options.maxResults = MAX_EVENTS_LIMIT; } var protoOpts = { service: 'Subscriber', method: 'pull', timeout: this.timeout }; var reqOpts = { subscription: this.name, returnImmediately: !!options.returnImmediately, maxMessages: options.maxResults }; this.activeRequest_ = this.request(protoOpts, reqOpts, function(err, resp) { self.activeRequest_ = null; if (err) { if (err.code === 504) { // Simulate a server timeout where no messages were received. resp = { receivedMessages: [] }; } else { callback(err, null, resp); return; } } var messages = arrify(resp.receivedMessages) .map(function(msg) { return Subscription.formatMessage_(msg, self.encoding); }) .map(self.decorateMessage_.bind(self)); self.refreshPausedStatus_(); if (self.autoAck && messages.length !== 0) { var ackIds = messages.map(prop('ackId')); self.ack(ackIds, function(err) { callback(err, messages, resp); }); } else { callback(null, messages, resp); } }); }; /** * Modify the ack deadline for a specific message. This method is useful to * indicate that more time is needed to process a message by the subscriber, or * to make the message available for redelivery if the processing was * interrupted. * * @resource [Subscriptions: modifyAckDeadline API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/modifyAckDeadline} * * @param {object} options - The configuration object. * @param {string|string[]} options.ackIds - The ack id(s) to change. * @param {number} options.seconds - Number of seconds after call is made to * set the deadline of the ack. * @param {Function=} callback - The callback function. * * @example * var options = { * ackIds: ['abc'], * seconds: 10 // Expire in 10 seconds from call. * }; * * subscription.setAckDeadline(options, function(err, apiResponse) {}); * * //- * // If the callback is omitted, we'll return a Promise. * //- * subscription.setAckDeadline(options).then(function(data) { * var apiResponse = data[0]; * }); */ Subscription.prototype.setAckDeadline = function(options, callback) { callback = callback || common.util.noop; var protoOpts = { service: 'Subscriber', method: 'modifyAckDeadline' }; var reqOpts = { subscription: this.name, ackIds: arrify(options.ackIds), ackDeadlineSeconds: options.seconds }; this.request(protoOpts, reqOpts, function(err, resp) { callback(err, resp); }); }; /** * Begin listening for events on the subscription. This method keeps track of * how many message listeners are assigned, and then removed, making sure * polling is handled automatically. * * As long as there is one active message listener, the connection is open. As * soon as there are no more message listeners, the connection is closed. * * @private * * @example * subscription.listenForEvents_(); */ Subscription.prototype.listenForEvents_ = function() { var self = this; this.on('newListener', function(event) { if (event === 'message') { self.messageListeners++; if (self.closed) { self.closed = false; self.startPulling_(); } } }); this.on('removeListener', function(event) { if (event === 'message' && --self.messageListeners === 0) { self.closed = true; if (self.activeRequest_ && self.activeRequest_.abort) { self.activeRequest_.abort(); } } }); }; /** * Update the status of `maxInProgress`. Å subscription becomes "paused" (not * pulling) when the number of messages that have yet to be ack'd or skipped * exceeds the user's specified `maxInProgress` value. * * This will start pulling when that event reverses: we were paused, but one or * more messages were just ack'd or skipped, freeing up room for more messages * to be consumed. * * @private */ Subscription.prototype.refreshPausedStatus_ = function() { var isCurrentlyPaused = this.paused; var inProgress = Object.keys(this.inProgressAckIds).length; this.paused = inProgress >= this.maxInProgress; if (isCurrentlyPaused && !this.paused && this.messageListeners > 0) { this.startPulling_(); } }; /** * Poll the backend for new messages. This runs a loop to ping the API at the * provided interval from the subscription's instantiation. If one wasn't * provided, the default value is 10 milliseconds. * * If messages are received, they are emitted on the `message` event. * * Note: This method is automatically called once a message event handler is * assigned to the description. * * To stop pulling, see {module:pubsub/subscription#close}. * * @private * * @example * subscription.startPulling_(); */ Subscription.prototype.startPulling_ = function() { var self = this; if (this.closed || this.paused) { return; } var maxResults; if (this.maxInProgress < Infinity) { maxResults = this.maxInProgress - Object.keys(this.inProgressAckIds).length; } this.pull({ returnImmediately: false, maxResults: maxResults }, function(err, messages, apiResponse) { if (err) { self.emit('error', err, apiResponse); } if (messages) { messages.forEach(function(message) { self.emit('message', message, apiResponse); }); } setTimeout(self.startPulling_.bind(self), self.interval); }); }; /*! Developer Documentation * * All async methods (except for streams) will return a Promise in the event * that a callback is omitted. */ common.util.promisifyAll(Subscription); module.exports = Subscription;