diff --git a/README.md b/README.md index 6ae24b037ac..9900d7f5ddf 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,7 @@ This client supports the following Google Cloud Platform services at a [General This client supports the following Google Cloud Platform services at a [Beta](#versioning) quality level: * [Cloud Natural Language](#cloud-natural-language-beta) (Beta) +* [Cloud Pub/Sub](#cloud-pubsub-alpha) (Beta) * [Cloud Spanner](#cloud-spanner-beta) (Beta) * [Cloud Vision](#cloud-vision-beta) (Beta) * [Google BigQuery](#google-bigquery-beta) (Beta) @@ -28,7 +29,6 @@ This client supports the following Google Cloud Platform services at an [Alpha]( * [Cloud Bigtable](#cloud-bigtable-alpha) (Alpha) * [Cloud DNS](#cloud-dns-alpha) (Alpha) -* [Cloud Pub/Sub](#cloud-pubsub-alpha) (Alpha) * [Cloud Resource Manager](#cloud-resource-manager-alpha) (Alpha) * [Cloud Speech](#cloud-speech-alpha) (Alpha) * [Google Compute Engine](#google-compute-engine-alpha) (Alpha) @@ -450,6 +450,57 @@ languageClient.analyzeSentiment({document: document}).then(function(responses) { ``` +## Cloud Pub/Sub (Beta) + +- [API Documentation][gcloud-pubsub-docs] +- [Official Documentation][cloud-pubsub-docs] + +#### Using the Cloud Pub/Sub API module + +``` +$ npm install --save @google-cloud/pubsub +``` + +```js +var pubsub = require('@google-cloud/pubsub'); +``` + +#### Authentication + +See [Authentication](#authentication). + +#### Preview + +```js +var pubsubClient = pubsub({ + projectId: 'grape-spaceship-123', + keyFilename: '/path/to/keyfile.json' +}); + +// Reference a topic that has been previously created. +var topic = pubsubClient.topic('my-topic'); + +// Publish a message to the topic. +var publisher = topic.publisher(); +var message = new Buffer('New message!'); + +publisher.publish(message, function(err, messageId) {}); + +// Subscribe to the topic. +topic.createSubscription('subscription-name', function(err, subscription) { + // Register listeners to start pulling for messages. + function onError(err) {} + function onMessage(message) {} + subscription.on('error', onError); + subscription.on('message', onMessage); + + // Remove listeners to stop pulling for messages. + subscription.removeListener('message', onMessage); + subscription.removeListener('error', onError); +}); +``` + + ## Cloud Spanner (Beta) - [API Documentation][gcloud-spanner-docs] @@ -816,54 +867,6 @@ zone.export('/zonefile.zone', function(err) {}); ``` -## Cloud Pub/Sub (Alpha) - -- [API Documentation][gcloud-pubsub-docs] -- [Official Documentation][cloud-pubsub-docs] - -#### Using the Cloud Pub/Sub API module - -``` -$ npm install --save @google-cloud/pubsub -``` - -```js -var pubsub = require('@google-cloud/pubsub'); -``` - -#### Authentication - -See [Authentication](#authentication). - -#### Preview - -```js -var pubsubClient = pubsub({ - projectId: 'grape-spaceship-123', - keyFilename: '/path/to/keyfile.json' -}); - -// Reference a topic that has been previously created. -var topic = pubsubClient.topic('my-topic'); - -// Publish a message to the topic. -topic.publish('New message!', function(err) {}); - -// Subscribe to the topic. -topic.subscribe('subscription-name', function(err, subscription) { - // Register listeners to start pulling for messages. - function onError(err) {} - function onMessage(message) {} - subscription.on('error', onError); - subscription.on('message', onMessage); - - // Remove listeners to stop pulling for messages. - subscription.removeListener('message', onMessage); - subscription.removeListener('error', onError); -}); -``` - - ## Cloud Resource Manager (Alpha) - [API Documentation][gcloud-resource-docs] diff --git a/packages/pubsub/README.md b/packages/pubsub/README.md index eae849cf6eb..d225bdf5f92 100644 --- a/packages/pubsub/README.md +++ b/packages/pubsub/README.md @@ -1,4 +1,4 @@ -# @google-cloud/pubsub ([Alpha][versioning]) +# @google-cloud/pubsub ([Beta][versioning]) > Cloud Pub/Sub Client Library for Node.js *Looking for more Google APIs than just Pub/Sub? You might want to check out [`google-cloud`][google-cloud].* @@ -20,10 +20,13 @@ var pubsub = require('@google-cloud/pubsub')({ var topic = pubsub.topic('my-topic'); // Publish a message to the topic. -topic.publish('New message!', function(err) {}); +var publisher = topic.publisher(); +var message = new Buffer('New message!'); + +publisher.publish(message, function(err, messageId) {}); // Subscribe to the topic. -topic.subscribe('subscription-name', function(err, subscription) { +topic.createSubscription('subscription-name', function(err, subscription) { // Register listeners to start pulling for messages. function onError(err) {} function onMessage(message) {} @@ -36,7 +39,7 @@ topic.subscribe('subscription-name', function(err, subscription) { }); // Promises are also supported by omitting callbacks. -topic.publish('New message!').then(function(data) { +publisher.publish(message).then(function(data) { var messageIds = data[0]; }); diff --git a/packages/pubsub/package.json b/packages/pubsub/package.json index 5f0c14e6422..36e14de2610 100644 --- a/packages/pubsub/package.json +++ b/packages/pubsub/package.json @@ -54,12 +54,12 @@ "@google-cloud/common": "^0.13.0", "@google-cloud/common-grpc": "^0.4.0", "arrify": "^1.0.0", + "async-each": "^1.0.1", "extend": "^3.0.0", + "google-auto-auth": "^0.7.1", "google-gax": "^0.13.0", "google-proto-files": "^0.12.0", "is": "^3.0.1", - "modelo": "^4.2.0", - "propprop": "^0.3.0", "uuid": "^3.0.1" }, "devDependencies": { diff --git a/packages/pubsub/src/connection-pool.js b/packages/pubsub/src/connection-pool.js new file mode 100644 index 00000000000..4899f953054 --- /dev/null +++ b/packages/pubsub/src/connection-pool.js @@ -0,0 +1,423 @@ +/*! + * Copyright 2017 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/connectionPool + */ + +'use strict'; + +var arrify = require('arrify'); +var common = require('@google-cloud/common'); +var each = require('async-each'); +var events = require('events'); +var grpc = require('grpc'); +var is = require('is'); +var util = require('util'); +var uuid = require('uuid'); + +var PKG = require('../package.json'); +var v1 = require('./v1'); + +// codes to retry streams +var RETRY_CODES = [ + 0, // ok + 1, // canceled + 4, // deadline exceeded + 8, // resource exhausted + 13, // internal error + 14 // unavailable +]; + +/** + * ConnectionPool is used to manage the stream connections created via + * StreamingPull rpc. + * + * @param {module:pubsub/subscription} subscription - The subscription to create + * connections for. + * @param {object=} options - Pool options. + * @param {number} options.maxConnections - Number of connections to create. + * Default: 5. + * @param {number} options.ackDeadline - The ack deadline to send when + * creating a connection. + */ +function ConnectionPool(subscription) { + this.subscription = subscription; + this.connections = new Map(); + + this.isPaused = false; + this.isOpen = false; + + this.failedConnectionAttempts = 0; + this.noConnectionsTime = 0; + + this.settings = { + maxConnections: subscription.maxConnections || 5, + ackDeadline: subscription.ackDeadline || 10000 + }; + + this.queue = []; + events.EventEmitter.call(this); + + // grpc related fields we need since we're bypassing gax + this.metadata_ = new grpc.Metadata(); + + this.metadata_.add('x-goog-api-client', [ + 'gl-node/' + process.versions.node, + 'gccl/' + PKG.version, + 'grpc/' + require('grpc/package.json').version + ].join(' ')); + + this.open(); +} + +util.inherits(ConnectionPool, events.EventEmitter); + +/** + * Acquires a connection from the pool. Optionally you can specify an id for a + * specific connection, but if it is no longer available it will return the + * first available connection. + * + * @param {string=} id - The id of the connection to retrieve. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while acquiring a + * connection. + * @param {stream} callback.connection - A duplex stream. + */ +ConnectionPool.prototype.acquire = function(id, callback) { + var self = this; + + if (is.fn(id)) { + callback = id; + id = null; + } + + if (!this.isOpen) { + callback(new Error('No connections available to make request.')); + return; + } + + // it's possible that by the time a user acks the connection could have + // closed, so in that case we'll just return any connection + if (!this.connections.has(id)) { + id = getFirstConnectionId(); + } + + var connection = this.connections.get(id); + + if (connection) { + callback(null, connection); + return; + } + + this.once('connected', function(connection) { + callback(null, connection); + }); + + function getFirstConnectionId() { + return self.connections.keys().next().value; + } +}; + +/** + * Ends each connection in the pool and closes the pool, preventing new + * connections from being created. + * + * @param {function} callback - The callback function. + * @param {?error} callback.error - An error returned while closing the pool. + */ +ConnectionPool.prototype.close = function(callback) { + var self = this; + var connections = Array.from(this.connections.values()); + + callback = callback || common.util.noop; + + this.isOpen = false; + self.connections.clear(); + this.queue.forEach(clearTimeout); + + this.queue.length = 0; + this.failedConnectionAttempts = 0; + this.noConnectionsTime = 0; + + each(connections, function(connection, onEndCallback) { + connection.end(onEndCallback); + }, callback); +}; + +/** + * Creates a connection. This is async but instead of providing a callback + * a `connected` event will fire once the connection is ready. + */ +ConnectionPool.prototype.createConnection = function() { + var self = this; + + this.getClient(function(err, client) { + if (err) { + self.emit('error', err); + return; + } + + var id = uuid.v4(); + var connection = client.streamingPull(self.metadata_); + var errorImmediateHandle; + + connection.on('error', function(err) { + // since this is a bidi stream it's possible that we recieve errors from + // reads or writes. We also want to try and cut down on the number of + // errors that we emit if other connections are still open. So by using + // setImmediate we're able to cancel the error message if it gets passed + // to the `status` event where we can check if the connection should be + // re-opened or if we should send the error to the user + errorImmediateHandle = setImmediate(self.emit.bind(self), 'error', err); + }); + + connection.on('metadata', function(metadata) { + if (!metadata.get('date').length) { + return; + } + + connection.isConnected = true; + self.noConnectionsTime = 0; + self.failedConnectionAttempts = 0; + self.emit('connected', connection); + }); + + connection.on('status', function(status) { + clearImmediate(errorImmediateHandle); + + connection.end(); + self.connections.delete(id); + + if (!connection.isConnected) { + self.failedConnectionAttempts += 1; + } + + if (!self.isConnected() && !self.noConnectionsTime) { + self.noConnectionsTime = Date.now(); + } + + if (self.shouldReconnect(status)) { + self.queueConnection(); + } else if (self.isOpen && !self.connections.size) { + var error = new Error(status.details); + error.code = status.code; + self.emit('error', error); + } + }); + + connection.on('data', function(data) { + arrify(data.receivedMessages).forEach(function(message) { + self.emit('message', self.createMessage(id, message)); + }); + }); + + if (self.isPaused) { + connection.pause(); + } + + connection.write({ + subscription: self.subscription.name, + streamAckDeadlineSeconds: self.settings.ackDeadline / 1000 + }); + + self.connections.set(id, connection); + }); +}; + +/** + * Creates a message object for the user. + * + * @param {string} connectionId - The connection id that the message was + * received on. + * @param {object} resp - The message response data from StreamingPull. + * @return {object} message - The message object. + */ +ConnectionPool.prototype.createMessage = function(connectionId, resp) { + var self = this; + + var pt = resp.message.publishTime; + var milliseconds = parseInt(pt.nanos, 10) / 1e6; + var originalDataLength = resp.message.data.length; + + return { + connectionId: connectionId, + ackId: resp.ackId, + id: resp.message.messageId, + attributes: resp.message.attributes, + publishTime: new Date(parseInt(pt.seconds, 10) * 1000 + milliseconds), + received: Date.now(), + data: resp.message.data, + // using get here to prevent user from overwriting data + get length() { + return originalDataLength; + }, + ack: function() { + self.subscription.ack_(this); + }, + nack: function() { + self.subscription.nack_(this); + } + }; +}; + +/** + * Gets the Subscriber client. We need to bypass GAX until they allow deadlines + * to be optional. + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error occurred while getting the client. + * @param {object} callback.client - The Subscriber client. + */ +ConnectionPool.prototype.getClient = function(callback) { + if (this.client) { + callback(null, this.client); + return; + } + + var self = this; + var pubsub = this.subscription.pubsub; + + pubsub.auth.getAuthClient(function(err, authClient) { + if (err) { + callback(err); + return; + } + + var credentials = grpc.credentials.combineChannelCredentials( + grpc.credentials.createSsl(), + grpc.credentials.createFromGoogleCredential(authClient) + ); + + var Subscriber = v1(pubsub.options).Subscriber; + + self.client = new Subscriber(v1.SERVICE_ADDRESS, credentials, { + 'grpc.max_receive_message_length': 20000001, + 'grpc.primary_user_agent': common.util.getUserAgentFromPackageJson(PKG) + }); + + callback(null, self.client); + }); +}; + +/** + * Check to see if at least one stream in the pool is connected. + * @return {boolean} + */ +ConnectionPool.prototype.isConnected = function() { + var interator = this.connections.values(); + var connection = interator.next().value; + + while (connection) { + if (connection.isConnected) { + return true; + } + + connection = interator.next().value; + } + + return false; +}; + +/** + * Creates specified number of connections and puts pool in open state. + */ +ConnectionPool.prototype.open = function() { + var existing = this.connections.size; + var max = this.settings.maxConnections; + + for (; existing < max; existing++) { + this.queueConnection(); + } + + this.isOpen = true; + this.failedConnectionAttempts = 0; + this.noConnectionsTime = Date.now(); +}; + +/** + * Pauses each of the connections, causing `message` events to stop firing. + */ +ConnectionPool.prototype.pause = function() { + this.isPaused = true; + + this.connections.forEach(function(connection) { + connection.pause(); + }); +}; + +/** + * Queues a connection to be created. If any previous connections have failed, + * it will apply a back off based on the number of failures. + */ +ConnectionPool.prototype.queueConnection = function() { + var self = this; + var delay = 0; + + if (this.failedConnectionAttempts > 0) { + delay = (Math.pow(2, this.failedConnectionAttempts) * 1000) + + (Math.floor(Math.random() * 1000)); + } + + var timeoutHandle = setTimeout(createConnection, delay); + this.queue.push(timeoutHandle); + + function createConnection() { + self.createConnection(); + self.queue.splice(self.queue.indexOf(timeoutHandle), 1); + } +}; + +/** + * Calls resume on each connection, allowing `message` events to fire off again. + */ +ConnectionPool.prototype.resume = function() { + this.isPaused = false; + + this.connections.forEach(function(connection) { + connection.resume(); + }); +}; + +/** + * Inspects a status object to determine whether or not we should try and + * reconnect. + * + * @param {object} status - The gRPC status object. + * @return {boolean} + */ +ConnectionPool.prototype.shouldReconnect = function(status) { + // If the pool was closed, we should definitely not reconnect + if (!this.isOpen) { + return false; + } + + // We should check to see if the status code is a non-recoverable error + if (RETRY_CODES.indexOf(status.code) === -1) { + return false; + } + + var exceededRetryLimit = this.noConnectionsTime && + Date.now() - this.noConnectionsTime > 300000; + + if (exceededRetryLimit) { + return false; + } + + return true; +}; + +module.exports = ConnectionPool; diff --git a/packages/pubsub/src/histogram.js b/packages/pubsub/src/histogram.js new file mode 100644 index 00000000000..e0674851721 --- /dev/null +++ b/packages/pubsub/src/histogram.js @@ -0,0 +1,80 @@ +/*! + * Copyright 2017 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/histogram + */ + +'use strict'; + +var MIN_VALUE = 10000; +var MAX_VALUE = 600000; + +/** + * The Histogram class is used to capture the lifespan of messages within the + * the client. These durations are then used to calculate the 99th percentile + * of ack deadlines for future messages. + */ +function Histogram() { + this.data = new Map(); + this.length = 0; +} + +/** + * Adds a value to the histogram. + * + * @param {numnber} value - The value in milliseconds. + */ +Histogram.prototype.add = function(value) { + value = Math.max(value, MIN_VALUE); + value = Math.min(value, MAX_VALUE); + value = Math.ceil(value / 1000) * 1000; + + if (!this.data.has(value)) { + this.data.set(value, 0); + } + + var count = this.data.get(value); + this.data.set(value, count + 1); + this.length += 1; +}; + +/** + * Retrieves the nth percentile of recorded values. + * + * @param {number} percent - The requested percentage. + * @return {number} + */ +Histogram.prototype.percentile = function(percent) { + percent = Math.min(percent, 100); + + var target = this.length - (this.length * (percent / 100)); + var keys = Array.from(this.data.keys()); + var key; + + for (var i = keys.length - 1; i > -1; i--) { + key = keys[i]; + target -= this.data.get(key); + + if (target <= 0) { + return key; + } + } + + return MIN_VALUE; +}; + +module.exports = Histogram; diff --git a/packages/pubsub/src/iam.js b/packages/pubsub/src/iam.js index fe6e3b997ac..a64a32fb8d8 100644 --- a/packages/pubsub/src/iam.js +++ b/packages/pubsub/src/iam.js @@ -69,7 +69,7 @@ var util = require('util'); */ function IAM(pubsub, id) { var config = { - baseUrl: pubsub.defaultBaseUrl_, + baseUrl: 'pubsub.googleapis.com', protosDir: path.resolve(__dirname, '../protos'), protoServices: { IAMPolicy: { diff --git a/packages/pubsub/src/index.js b/packages/pubsub/src/index.js index a5b13483b11..e70bc171d63 100644 --- a/packages/pubsub/src/index.js +++ b/packages/pubsub/src/index.js @@ -20,13 +20,13 @@ 'use strict'; -var arrify = require('arrify'); var common = require('@google-cloud/common'); -var commonGrpc = require('@google-cloud/common-grpc'); var extend = require('extend'); +var googleAuth = require('google-auto-auth'); var is = require('is'); -var path = require('path'); -var util = require('util'); + +var PKG = require('../package.json'); +var v1 = require('./v1'); /** * @type {module:pubsub/snapshot} @@ -46,17 +46,6 @@ var Subscription = require('./subscription.js'); */ var Topic = require('./topic.js'); -/** - * @type {object} - GAX's default configuration. - * @private - */ -var GAX_CONFIG = { - Publisher: require('./v1/publisher_client_config.json'). - interfaces['google.pubsub.v1.Publisher'], - Subscriber: require('./v1/subscriber_client_config.json'). - interfaces['google.pubsub.v1.Subscriber'] -}; - /** * [Cloud Pub/Sub](https://developers.google.com/pubsub/overview) is a * reliable, many-to-many, asynchronous messaging service from Cloud @@ -79,36 +68,145 @@ function PubSub(options) { return new PubSub(options); } - this.defaultBaseUrl_ = 'pubsub.googleapis.com'; - this.determineBaseUrl_(options.apiEndpoint); - - var config = { - baseUrl: this.baseUrl_, - customEndpoint: this.customEndpoint_, - protosDir: path.resolve(__dirname, '../protos'), - protoServices: { - Publisher: { - path: 'google/pubsub/v1/pubsub.proto', - service: 'pubsub.v1' - }, - Subscriber: { - path: 'google/pubsub/v1/pubsub.proto', - service: 'pubsub.v1' - } - }, - scopes: [ - 'https://www.googleapis.com/auth/pubsub', - 'https://www.googleapis.com/auth/cloud-platform' - ], - packageJson: require('../package.json') - }; + this.options = extend({ + scopes: v1.ALL_SCOPES, + 'grpc.max_receive_message_length': 20000001, + libName: 'gccl', + libVersion: PKG.version + }, options); - this.options = options; + this.determineBaseUrl_(); - commonGrpc.Service.call(this, config, options); + this.api = {}; + this.auth = googleAuth(this.options); + this.projectId = this.options.projectId || '{{projectId}}'; } -util.inherits(PubSub, commonGrpc.Service); +/** + * Create a subscription to a topic. + * + * @resource [Subscriptions: create API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/create} + * + * @throws {Error} If a Topic instance or topic name is not provided. + * @throws {Error} If a subscription name is not provided. + * + * @param {module:pubsub/topic|string} topic - The Topic to create a + * subscription to. + * @param {string=} name - The name of the subscription. + * @param {object=} options - See a + * [Subscription resource](https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions) + * @param {object} options.flowControl - Flow control configurations for + * receiving messages. Note that these options do not persist across + * subscription instances. + * @param {number} options.flowControl.maxBytes - The maximum number of bytes + * in un-acked messages to allow before the subscription pauses incoming + * messages. Defaults to 20% of free memory. + * @param {number} options.flowControl.maxMessages - The maximum number of + * un-acked messages to allow before the subscription pauses incoming + * messages. Default: Infinity. + * @param {object} options.gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {number|date} options.messageRetentionDuration - Set this to override + * the default duration of 7 days. This value is expected in seconds. + * Acceptable values are in the range of 10 minutes and 7 days. + * @param {string} options.pushEndpoint - A URL to a custom endpoint that + * messages should be pushed to. + * @param {boolean} options.retainAckedMessages - If set, acked messages are + * retained in the subscription's backlog for the length of time specified + * by `options.messageRetentionDuration`. Default: `false` + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request + * @param {module:pubsub/subscription} callback.subscription - The subscription. + * @param {object} callback.apiResponse - The full API response. + * + * @example + * //- + * // Subscribe to a topic. (Also see {module:pubsub/topic#createSubscription}). + * //- + * var topic = 'messageCenter'; + * var name = 'newMessages'; + * + * var callback = function(err, subscription, apiResponse) {}; + * + * pubsub.createSubscription(topic, name, callback); + * + * //- + * // Customize the subscription. + * //- + * pubsub.createSubscription(topic, name, { + * ackDeadline: 90000 // 90 seconds + * }, callback); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * pubsub.createSubscription(topic, name).then(function(data) { + * var subscription = data[0]; + * var apiResponse = data[1]; + * }); + */ +PubSub.prototype.createSubscription = function(topic, name, options, callback) { + if (!is.string(topic) && !(topic instanceof Topic)) { + throw new Error('A Topic is required for a new subscription.'); + } + + if (!is.string(name)) { + throw new Error('A subscription name is required.'); + } + + if (is.string(topic)) { + topic = this.topic(topic); + } + + if (is.fn(options)) { + callback = options; + options = {}; + } + + options = options || {}; + + var subscription = this.subscription(name, options); + + var reqOpts = extend({ + topic: topic.name, + name: subscription.name + }, options); + + delete reqOpts.gaxOpts; + delete reqOpts.flowControl; + + if (options.messageRetentionDuration) { + reqOpts.retainAckedMessages = true; + + reqOpts.messageRetentionDuration = { + seconds: options.messageRetentionDuration, + nanos: 0 + }; + } + + if (options.pushEndpoint) { + delete reqOpts.pushEndpoint; + + reqOpts.pushConfig = { + pushEndpoint: options.pushEndpoint + }; + } + + this.request({ + client: 'subscriberClient', + method: 'createSubscription', + reqOpts: reqOpts, + gaxOpts: options.gaxOpts + }, function(err, resp) { + if (err && err.code !== 6) { + callback(err, null, resp); + return; + } + + subscription.metadata = resp; + callback(null, subscription, resp); + }); +}; /** * Create a topic with the given name. @@ -116,6 +214,8 @@ util.inherits(PubSub, commonGrpc.Service); * @resource [Topics: create API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/create} * * @param {string} name - Name of the topic. + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {function=} callback - The callback function. * @param {?error} callback.err - An error from the API call, may be null. * @param {module:pubsub/topic} callback.topic - The newly created topic. @@ -137,41 +237,70 @@ util.inherits(PubSub, commonGrpc.Service); * var apiResponse = data[1]; * }); */ -PubSub.prototype.createTopic = function(name, callback) { - var self = this; - - callback = callback || common.util.noop; - - var protoOpts = { - service: 'Publisher', - method: 'createTopic' - }; +PubSub.prototype.createTopic = function(name, gaxOpts, callback) { + var topic = this.topic(name); var reqOpts = { - name: Topic.formatName_(this.projectId, name) + name: topic.name }; - this.request(protoOpts, reqOpts, function(err, resp) { + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } + + this.request({ + client: 'publisherClient', + method: 'createTopic', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function(err, resp) { if (err) { callback(err, null, resp); return; } - var topic = self.topic(name); topic.metadata = resp; - callback(null, topic, resp); }); }; +/** + * Determine the appropriate endpoint to use for API requests, first trying the + * local `apiEndpoint` parameter. If the `apiEndpoint` parameter is null we try + * Pub/Sub emulator environment variable (PUBSUB_EMULATOR_HOST), otherwise the + * default JSON API. + * + * @private + */ +PubSub.prototype.determineBaseUrl_ = function() { + var apiEndpoint = this.options.apiEndpoint; + + if (!apiEndpoint && !process.env.PUBSUB_EMULATOR_HOST) { + return; + } + + var baseUrl = apiEndpoint || process.env.PUBSUB_EMULATOR_HOST; + var leadingProtocol = new RegExp('^https*://'); + var trailingSlashes = new RegExp('/*$'); + + var baseUrlParts = baseUrl + .replace(leadingProtocol, '') + .replace(trailingSlashes, '') + .split(':'); + + this.options.servicePath = baseUrlParts[0]; + this.options.port = baseUrlParts[1]; +}; + /** * Get a list of snapshots. * * @param {object=} options - Configuration object. * @param {boolean} options.autoPaginate - Have pagination handled * automatically. Default: true. - * @param {number} options.maxApiCalls - Maximum number of API calls to make. - * @param {number} options.maxResults - Maximum number of results to return. + * @param {object} options.gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {number} options.pageSize - Maximum number of results to return. * @param {string} options.pageToken - Page token. * @param {function} callback - The callback function. @@ -187,21 +316,6 @@ PubSub.prototype.createTopic = function(name, callback) { * }); * * //- - * // To control how many API requests are made and page through the results - * // manually, set `autoPaginate` to `false`. - * //- - * var callback = function(err, snapshots, nextQuery, apiResponse) { - * if (nextQuery) { - * // More results exist. - * pubsub.getSnapshots(nextQuery, callback); - * } - * }; - * - * pubsub.getSnapshots({ - * autoPaginate: false - * }, callback); - * - * //- * // If the callback is omitted, we'll return a Promise. * //- * pubsub.getSnapshots().then(function(data) { @@ -216,35 +330,34 @@ PubSub.prototype.getSnapshots = function(options, callback) { options = {}; } - var protoOpts = { - service: 'Subscriber', - method: 'listSnapshots' - }; - - var reqOpts = extend({}, options); - - reqOpts.project = 'projects/' + this.projectId; - - this.request(protoOpts, reqOpts, function(err, resp) { - if (err) { - callback(err, null, null, resp); - return; - } - - var snapshots = arrify(resp.snapshots).map(function(snapshot) { - var snapshotInstance = self.snapshot(snapshot.name); - snapshotInstance.metadata = snapshot; - return snapshotInstance; - }); - - var nextQuery = null; - - if (resp.nextPageToken) { - nextQuery = options; - nextQuery.pageToken = resp.nextPageToken; + var reqOpts = extend({ + project: 'projects/' + this.projectId + }, options); + + delete reqOpts.gaxOpts; + delete reqOpts.autoPaginate; + + var gaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + this.request({ + client: 'subscriberClient', + method: 'listSnapshots', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function() { + var snapshots = arguments[1]; + + if (snapshots) { + arguments[1] = snapshots.map(function(snapshot) { + var snapshotInstance = self.snapshot(snapshot.name); + snapshotInstance.metadata = snapshot; + return snapshotInstance; + }); } - callback(null, snapshots, nextQuery, resp); + callback.apply(null, arguments); }); }; @@ -293,8 +406,8 @@ PubSub.prototype.getSnapshotsStream = * @param {object=} options - Configuration object. * @param {boolean} options.autoPaginate - Have pagination handled * automatically. Default: true. - * @param {number} options.maxApiCalls - Maximum number of API calls to make. - * @param {number} options.maxResults - Maximum number of results to return. + * @param {object} options.gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {number} options.pageSize - Maximum number of results to return. * @param {string} options.pageToken - Page token. * @param {string|module:pubsub/topic} options.topic - The name of the topic to @@ -314,21 +427,6 @@ PubSub.prototype.getSnapshotsStream = * }); * * //- - * // To control how many API requests are made and page through the results - * // manually, set `autoPaginate` to `false`. - * //- - * var callback = function(err, subscriptions, nextQuery, apiResponse) { - * if (nextQuery) { - * // More results exist. - * pubsub.getSubscriptions(nextQuery, callback); - * } - * }; - * - * pubsub.getSubscriptions({ - * autoPaginate: false - * }, callback); - * - * //- * // If the callback is omitted, we'll return a Promise. * //- * pubsub.getSubscriptions().then(function(data) { @@ -343,56 +441,44 @@ PubSub.prototype.getSubscriptions = function(options, callback) { options = {}; } - var protoOpts = {}; - var reqOpts = extend({}, options); + var topic = options.topic; - if (options.topic) { - protoOpts = { - service: 'Publisher', - method: 'listTopicSubscriptions' - }; - - if (options.topic instanceof Topic) { - reqOpts.topic = options.topic.name; - } else { - reqOpts.topic = options.topic; + if (topic) { + if (!(topic instanceof Topic)) { + topic = this.topic(topic); } - } else { - protoOpts = { - service: 'Subscriber', - method: 'listSubscriptions' - }; - reqOpts.project = 'projects/' + this.projectId; + return topic.getSubscriptions(options, callback); } - this.request(protoOpts, reqOpts, function(err, resp) { - if (err) { - callback(err, null, null, resp); - return; - } - var subscriptions = arrify(resp.subscriptions).map(function(sub) { - // Depending on if we're using a subscriptions.list or - // topics.subscriptions.list API endpoint, we will get back a - // Subscription resource or just the name of the subscription. - var subscriptionInstance = self.subscription(sub.name || sub); + var reqOpts = extend({}, options); - if (sub.name) { + reqOpts.project = 'projects/' + this.projectId; + delete reqOpts.gaxOpts; + delete reqOpts.autoPaginate; + + var gaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + this.request({ + client: 'subscriberClient', + method: 'listSubscriptions', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function() { + var subscriptions = arguments[1]; + + if (subscriptions) { + arguments[1] = subscriptions.map(function(sub) { + var subscriptionInstance = self.subscription(sub.name); subscriptionInstance.metadata = sub; - } - - return subscriptionInstance; - }); - - var nextQuery = null; - - if (resp.nextPageToken) { - nextQuery = options; - nextQuery.pageToken = resp.nextPageToken; + return subscriptionInstance; + }); } - callback(null, subscriptions, nextQuery, resp); + callback.apply(null, arguments); }); }; @@ -433,10 +519,10 @@ PubSub.prototype.getSubscriptionsStream = * @resource [Topics: list API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/list} * * @param {object=} query - Query object. - * @param {boolean} options.autoPaginate - Have pagination handled + * @param {boolean} query.autoPaginate - Have pagination handled * automatically. Default: true. - * @param {number} options.maxApiCalls - Maximum number of API calls to make. - * @param {number} options.maxResults - Maximum number of results to return. + * @param {object} query.gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {number} query.pageSize - Max number of results to return. * @param {string} query.pageToken - Page token. * @param {function} callback - The callback function. @@ -460,63 +546,48 @@ PubSub.prototype.getSubscriptionsStream = * }, function(err, topics) {}); * * //- - * // To control how many API requests are made and page through the results - * // manually, set `autoPaginate` to `false`. - * //- - * var callback = function(err, rows, nextQuery, apiResponse) { - * if (nextQuery) { - * // More results exist. - * pubsub.getTopics(nextQuery, callback); - * } - * }; - * - * pubsub.getTopics({ - * autoPaginate: false - * }, callback); - * - * //- * // If the callback is omitted, we'll return a Promise. * //- * pubsub.getTopics().then(function(data) { * var topics = data[0]; * }); */ -PubSub.prototype.getTopics = function(query, callback) { +PubSub.prototype.getTopics = function(options, callback) { var self = this; - if (!callback) { - callback = query; - query = {}; + if (is.fn(options)) { + callback = options; + options = {}; } - var protoOpts = { - service: 'Publisher', - method: 'listTopics' - }; - var reqOpts = extend({ project: 'projects/' + this.projectId - }, query); - - this.request(protoOpts, reqOpts, function(err, result) { - if (err) { - callback(err, null, result); - return; - } - - var topics = arrify(result.topics).map(function(topic) { - var topicInstance = self.topic(topic.name); - topicInstance.metadata = topic; - return topicInstance; - }); - - var nextQuery = null; - if (result.nextPageToken) { - nextQuery = query; - nextQuery.pageToken = result.nextPageToken; + }, options); + + delete reqOpts.gaxOpts; + delete reqOpts.autoPaginate; + + var gaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + this.request({ + client: 'publisherClient', + method: 'listTopics', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function() { + var topics = arguments[1]; + + if (topics) { + arguments[1] = topics.map(function(topic) { + var topicInstance = self.topic(topic.name); + topicInstance.metadata = topic; + return topicInstance; + }); } - callback(null, topics, nextQuery, result); + callback.apply(null, arguments); }); }; @@ -550,150 +621,41 @@ PubSub.prototype.getTopics = function(query, callback) { PubSub.prototype.getTopicsStream = common.paginator.streamify('getTopics'); /** - * Create a subscription to a topic. - * - * All generated subscription names share a common prefix, `autogenerated-`. - * - * @resource [Subscriptions: create API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/create} + * Funnel all API requests through this method, to be sure we have a project ID. * - * @throws {Error} If a Topic instance or topic name is not provided. - * @throws {Error} If a subName is not provided. - * - * @param {module:pubsub/topic|string} topic - The Topic to create a - * subscription to. - * @param {string=} subName - The name of the subscription. If a name is not - * provided, a random subscription name will be generated and created. - * @param {object=} options - See a - * [Subscription resource](https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions) - * @param {number} options.ackDeadlineSeconds - The maximum time after receiving - * a message that you must ack a message before it is redelivered. - * @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 {number} options.maxInProgress - Maximum messages to consume - * simultaneously. - * @param {number|date} options.messageRetentionDuration - Set this to override - * the default duration of 7 days. This value is expected in seconds. - * Acceptable values are in the range of 10 minutes and 7 days. - * @param {string} options.pushEndpoint - A URL to a custom endpoint that - * messages should be pushed to. - * @param {boolean} options.retainAckedMessages - If set, acked messages are - * retained in the subscription's backlog for 7 days (unless overriden by - * `options.messageRetentionDuration`). Default: `false` - * @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. - * @param {function} callback - The callback function. - * @param {?error} callback.err - An error returned while making this request - * @param {module:pubsub/subscription} callback.subscription - The subscription. - * @param {object} callback.apiResponse - The full API response. - * - * @example - * //- - * // Subscribe to a topic. (Also see {module:pubsub/topic#subscribe}). - * //- - * var topic = 'messageCenter'; - * var name = 'newMessages'; - * - * pubsub.subscribe(topic, name, function(err, subscription, apiResponse) {}); - * - * //- - * // Omit the name to have one generated automatically. All generated names - * // share a common prefix, `autogenerated-`. - * //- - * pubsub.subscribe(topic, function(err, subscription, apiResponse) { - * // subscription.name = The generated name. - * }); - * - * //- - * // Customize the subscription. - * //- - * pubsub.subscribe(topic, name, { - * ackDeadlineSeconds: 90, - * autoAck: true, - * interval: 30 - * }, function(err, subscription, apiResponse) {}); + * @private * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * pubsub.subscribe(topic, name).then(function(data) { - * var subscription = data[0]; - * var apiResponse = data[1]; - * }); + * @param {object} config - Configuration object. + * @param {object} config.gaxOpts - GAX options. + * @param {function} config.method - The gax method to call. + * @param {object} config.reqOpts - Request options. + * @param {function=} callback - The callback function. */ -PubSub.prototype.subscribe = function(topic, subName, options, callback) { - if (!is.string(topic) && !(topic instanceof Topic)) { - throw new Error('A Topic is required for a new subscription.'); - } - - if (is.string(topic)) { - topic = this.topic(topic); - } - - if (is.object(subName)) { - callback = options; - options = subName; - subName = ''; - } - - if (is.fn(subName)) { - callback = subName; - subName = ''; - } - - if (is.fn(options)) { - callback = options; - options = {}; - } - - options = options || {}; - - var subscription = this.subscription(subName, options); - - var protoOpts = { - service: 'Subscriber', - method: 'createSubscription', - timeout: options.timeout - }; - - var reqOpts = extend(true, {}, options, { - topic: topic.name, - name: subscription.name - }); - - if (reqOpts.messageRetentionDuration) { - reqOpts.retainAckedMessages = true; +PubSub.prototype.request = function(config, callback) { + var self = this; - reqOpts.messageRetentionDuration = { - seconds: reqOpts.messageRetentionDuration, - nanos: 0 - }; + if (global.GCLOUD_SANDBOX_ENV) { + return; } - if (reqOpts.pushEndpoint) { - reqOpts.pushConfig = { - pushEndpoint: reqOpts.pushEndpoint - }; - } + self.auth.getProjectId(function(err, projectId) { + if (err) { + callback(err); + return; + } - delete reqOpts.autoAck; - delete reqOpts.encoding; - delete reqOpts.interval; - delete reqOpts.maxInProgress; - delete reqOpts.pushEndpoint; - delete reqOpts.timeout; + var gaxClient = self.api[config.client]; - this.request(protoOpts, reqOpts, function(err, resp) { - if (err && err.code !== 409) { - callback(err, null, resp); - return; + if (!gaxClient) { + // Lazily instantiate client. + gaxClient = v1(self.options)[config.client](self.options); + self.api[config.client] = gaxClient; } - callback(null, subscription, resp); + var reqOpts = extend(true, {}, config.reqOpts); + reqOpts = common.util.replaceProjectIdToken(reqOpts, projectId); + + gaxClient[config.method](reqOpts, config.gaxOpts, callback); }); }; @@ -722,22 +684,21 @@ PubSub.prototype.snapshot = function(name) { * requests. You will receive a {module:pubsub/subscription} object, * which will allow you to interact with a subscription. * - * All generated names share a common prefix, `autogenerated-`. + * @throws {Error} If subscription name is omitted. * - * @param {string=} name - The name of the subscription. If a name is not - * provided, a random subscription name will be generated. + * @param {string} name - Name of the subscription. * @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 {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. + * @param {object} options.flowControl - Flow control configurations for + * receiving messages. Note that these options do not persist across + * subscription instances. + * @param {number} options.flowControl.maxBytes - The maximum number of bytes + * in un-acked messages to allow before the subscription pauses incoming + * messages. Defaults to 20% of free memory. + * @param {number} options.flowControl.maxMessages - The maximum number of + * un-acked messages to allow before the subscription pauses incoming + * messages. Default: Infinity. + * @param {number} options.maxConnections - Use this to limit the number of + * connections to be used when sending and receiving messages. Default: 5. * @return {module:pubsub/subscription} * * @example @@ -750,19 +711,15 @@ PubSub.prototype.snapshot = function(name) { * // 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. + * // message.publishTime = Timestamp when Pub/Sub received the message. * }); */ PubSub.prototype.subscription = function(name, options) { - if (is.object(name)) { - options = name; - name = undefined; + if (!name) { + throw new Error('A name must be specified for a subscription.'); } - options = options || {}; - options.name = name; - - return new Subscription(this, options); + return new Subscription(this, name, options); }; /** @@ -775,58 +732,13 @@ PubSub.prototype.subscription = function(name, options) { * * @example * var topic = pubsub.topic('my-topic'); - * - * topic.publish({ - * data: 'New message!' - * }, function(err) {}); */ -PubSub.prototype.topic = function(name) { +PubSub.prototype.topic = function(name, options) { if (!name) { - throw new Error('A name must be specified for a new topic.'); - } - - return new Topic(this, name); -}; - -/** - * Intercept the call to {module:common/grpc-service#request}, making sure the - * correct timeouts are set. - * - * @private - */ -PubSub.prototype.request = function(protoOpts) { - var method = protoOpts.method; - var camelCaseMethod = method[0].toUpperCase() + method.substr(1); - var config = GAX_CONFIG[protoOpts.service].methods[camelCaseMethod]; - - if (is.undefined(arguments[0].timeout)) { - arguments[0].timeout = config.timeout_millis; - } - - commonGrpc.Service.prototype.request.apply(this, arguments); -}; - -/** - * Determine the appropriate endpoint to use for API requests, first trying the - * local `apiEndpoint` parameter. If the `apiEndpoint` parameter is null we try - * Pub/Sub emulator environment variable (PUBSUB_EMULATOR_HOST), otherwise the - * default JSON API. - * - * @private - */ -PubSub.prototype.determineBaseUrl_ = function(apiEndpoint) { - var baseUrl = this.defaultBaseUrl_; - var leadingProtocol = new RegExp('^https*://'); - var trailingSlashes = new RegExp('/*$'); - - if (apiEndpoint || process.env.PUBSUB_EMULATOR_HOST) { - this.customEndpoint_ = true; - baseUrl = apiEndpoint || process.env.PUBSUB_EMULATOR_HOST; + throw new Error('A name must be specified for a topic.'); } - this.baseUrl_ = baseUrl - .replace(leadingProtocol, '') - .replace(trailingSlashes, ''); + return new Topic(this, name, options); }; /*! Developer Documentation @@ -853,8 +765,5 @@ common.util.promisifyAll(PubSub, { ] }); -PubSub.Subscription = Subscription; -PubSub.Topic = Topic; - module.exports = PubSub; -module.exports.v1 = require('./v1'); +module.exports.v1 = v1; diff --git a/packages/pubsub/src/publisher.js b/packages/pubsub/src/publisher.js new file mode 100644 index 00000000000..b84f634a590 --- /dev/null +++ b/packages/pubsub/src/publisher.js @@ -0,0 +1,226 @@ +/*! + * Copyright 2017 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/publisher + */ + +'use strict'; + +var arrify = require('arrify'); +var common = require('@google-cloud/common'); +var each = require('async-each'); +var extend = require('extend'); +var is = require('is'); + +/*! Developer Documentation. + * + * @param {module:pubsub/topic} topic - The topic associated with this + * publisher. + * @param {object=} options - Configuration object. + * @param {object} options.batching - Batching settings. + * @param {number} options.batching.maxBytes - The maximum number of bytes to + * buffer before sending a payload. + * @param {number} options.batching.maxMessages - The maximum number of messages + * to buffer before sending a payload. + * @param {number} options.batching.maxMilliseconds - The maximum duration to + * wait before sending a payload. + */ +/** + * A Publisher object allows you to publish messages to a specific topic. + * + * @constructor + * @alias module:pubsub/publisher + * + * @resource [Topics: publish API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/publish} + * + * @example + * var topic = pubsub.topic('my-topic'); + * var publisher = topic.publisher(); + */ +function Publisher(topic, options) { + options = extend(true, { + batching: { + maxBytes: Math.pow(1024, 2) * 5, + maxMessages: 1000, + maxMilliseconds: 1000 + } + }, options); + + this.topic = topic; + + // this object keeps track of all messages scheduled to be published + // queued is essentially the `messages` field for the publish rpc req opts + // bytes is used to track the size of the combined payload + // callbacks is an array of callbacks - each callback is associated with a + // specific message. + this.inventory_ = { + callbacks: [], + queued: [], + bytes: 0 + }; + + this.settings = { + batching: { + maxBytes: Math.min(options.batching.maxBytes, Math.pow(1024, 2) * 9), + maxMessages: Math.min(options.batching.maxMessages, 1000), + maxMilliseconds: options.batching.maxMilliseconds + } + }; + + this.timeoutHandle_ = null; +} + +/** + * Publish the provided message. + * + * @throws {TypeError} If data is not a Buffer object. + * + * @param {buffer} data - The message data. This must come in the form of a + * Buffer object. + * @param {object=} attributes - Optional attributes for this message. + * @param {function=} callback - The callback function. If omitted a Promise + * will be returned. + * @param {?error} callback.error - An error returned while making this request. + * @param {string} callback.messageId - The id for the message. + * + * @example + * var data = new Buffer('Hello, world!'); + * + * var callback = function(err, messageId) { + * if (err) { + * // Error handling omitted. + * } + * }; + * + * publisher.publish(data, callback); + * + * //- + * // Optionally you can provide an object containing attributes for the + * // message. + * //- + * var attributes = { + * key: 'value' + * }; + * + * publisher.publish(data, attributes, callback); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * publisher.publish(data).then(function(messageId) {}); + */ +Publisher.prototype.publish = function(data, attrs, callback) { + if (!(data instanceof Buffer)) { + throw new TypeError('Data must be in the form of a Buffer.'); + } + + if (is.fn(attrs)) { + callback = attrs; + attrs = {}; + } + + var opts = this.settings.batching; + + // if this message puts us over the maxBytes option, then let's ship + // what we have and add it to the next batch + if ((this.inventory_.bytes + data.length) > opts.maxBytes) { + this.publish_(); + } + + // add it to the queue! + this.queue_(data, attrs, callback); + + // next lets check if this message brings us to the message cap or if we + // magically hit the max byte limit + var hasMaxMessages = this.inventory_.queued.length === opts.maxMessages; + + if (this.inventory_.bytes === opts.maxBytes || hasMaxMessages) { + this.publish_(); + return; + } + + // otherwise let's set a timeout to send the next batch + if (!this.timeoutHandle_) { + this.timeoutHandle_ = setTimeout( + this.publish_.bind(this), opts.maxMilliseconds); + } +}; + +/** + * This publishes a batch of messages and should never be called directly. + * + * @private + */ +Publisher.prototype.publish_ = function() { + var callbacks = this.inventory_.callbacks; + var messages = this.inventory_.queued; + + this.inventory_.callbacks = []; + this.inventory_.queued = []; + this.inventory_.bytes = 0; + + clearTimeout(this.timeoutHandle_); + this.timeoutHandle_ = null; + + var reqOpts = { + topic: this.topic.name, + messages: messages + }; + + this.topic.request({ + client: 'publisherClient', + method: 'publish', + reqOpts: reqOpts + }, function(err, resp) { + var messageIds = arrify(resp && resp.messageIds); + + each(callbacks, function(callback, next) { + var messageId = messageIds[callbacks.indexOf(callback)]; + + callback(err, messageId); + next(); + }); + }); +}; + +/** + * Queues message to be sent to the server. + * + * @private + * + * @param {buffer} data - The message data. + * @param {object} attributes - The message attributes. + * @param {function} callback - The callback function. + */ +Publisher.prototype.queue_ = function(data, attrs, callback) { + this.inventory_.queued.push({ + data: data, + attributes: attrs + }); + + this.inventory_.bytes += data.length; + this.inventory_.callbacks.push(callback); +}; + +/*! Developer Documentation + * + * All async methods (except for streams) will return a Promise in the event + * that a callback is omitted. + */ +common.util.promisifyAll(Publisher); + +module.exports = Publisher; diff --git a/packages/pubsub/src/snapshot.js b/packages/pubsub/src/snapshot.js index 4a578191e49..ff25e0f1340 100644 --- a/packages/pubsub/src/snapshot.js +++ b/packages/pubsub/src/snapshot.js @@ -1,5 +1,5 @@ /*! - * Copyright 2014 Google Inc. All Rights Reserved. + * Copyright 2017 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. @@ -20,9 +20,8 @@ 'use strict'; -var commonGrpc = require('@google-cloud/common-grpc'); +var common = require('@google-cloud/common'); var is = require('is'); -var util = require('util'); /** * A Snapshot object will give you access to your Cloud Pub/Sub snapshot. @@ -90,56 +89,10 @@ var util = require('util'); * }); */ function Snapshot(parent, name) { - var projectId = parent.projectId; - - if (!projectId && parent.parent) { - projectId = parent.parent.projectId; - } - - this.name = Snapshot.formatName_(projectId, name); - - var methods = { - /** - * Delete the snapshot. - * - * @param {function=} callback - The callback function. - * @param {?error} callback.err - An error returned while making this - * request. - * @param {object} callback.apiResponse - The full API response from the - * service. - * - * @example - * snapshot.delete(function(err, apiResponse) {}); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * snapshot.delete().then(function(data) { - * var apiResponse = data[0]; - * }); - */ - delete: { - protoOpts: { - service: 'Subscriber', - method: 'deleteSnapshot' - }, - reqOpts: { - snapshot: this.name - } - } - }; - - var config = { - parent: parent, - id: this.name, - methods: methods - }; - - var isSubscription = is.fn(parent.createSnapshot); - - if (isSubscription) { - config.createMethod = parent.createSnapshot.bind(parent); + this.parent = parent; + this.name = Snapshot.formatName_(parent.projectId, name); + if (is.fn(parent.createSnapshot)) { /** * Create a snapshot with the given name. * @@ -174,8 +127,10 @@ function Snapshot(parent, name) { * var apiResponse = data[1]; * }); */ - methods.create = true; + this.create = parent.createSnapshot.bind(parent, name); + } + if (is.fn(parent.seek)) { /** * Seeks an existing subscription to the snapshot. * @@ -202,12 +157,8 @@ function Snapshot(parent, name) { */ this.seek = parent.seek.bind(parent, name); } - - commonGrpc.ServiceObject.call(this, config); } -util.inherits(Snapshot, commonGrpc.ServiceObject); - /** * Format the name of a snapshot. A snapshot's full name is in the format of * projects/{projectId}/snapshots/{snapshotName} @@ -218,4 +169,44 @@ Snapshot.formatName_ = function(projectId, name) { return 'projects/' + projectId + '/snapshots/' + name.split('/').pop(); }; +/** + * Delete the snapshot. + * + * @param {function=} callback - The callback function. + * @param {?error} callback.err - An error returned while making this + * request. + * @param {object} callback.apiResponse - The full API response from the + * service. + * + * @example + * snapshot.delete(function(err, apiResponse) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * snapshot.delete().then(function(data) { + * var apiResponse = data[0]; + * }); + */ +Snapshot.prototype.delete = function(callback) { + var reqOpts = { + snapshot: this.name + }; + + callback = callback || common.util.noop; + + this.parent.request({ + client: 'subscriberClient', + method: 'deleteSnapshot', + reqOpts: reqOpts + }, callback); +}; + +/*! Developer Documentation + * + * All async methods (except for streams) will return a Promise in the event + * that a callback is omitted. + */ +common.util.promisifyAll(Snapshot); + module.exports = Snapshot; diff --git a/packages/pubsub/src/subscription.js b/packages/pubsub/src/subscription.js index 91847efe974..42683f15c35 100644 --- a/packages/pubsub/src/subscription.js +++ b/packages/pubsub/src/subscription.js @@ -20,50 +20,40 @@ 'use strict'; -var arrify = require('arrify'); var common = require('@google-cloud/common'); -var commonGrpc = require('@google-cloud/common-grpc'); var events = require('events'); +var extend = require('extend'); var is = require('is'); -var modelo = require('modelo'); -var prop = require('propprop'); -var uuid = require('uuid'); +var os = require('os'); +var util = require('util'); /** - * @type {module:pubsub/iam} + * @type {module:pubsub/connectionPool} * @private */ -var IAM = require('./iam.js'); +var ConnectionPool = require('./connection-pool.js'); /** - * @type {module:pubsub/snapshot} + * @type {module:pubsub/histogram} * @private */ -var Snapshot = require('./snapshot.js'); +var Histogram = require('./histogram.js'); + +/** + * @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. + * @type {module:pubsub/snapshot} * @private */ -var PUBSUB_API_TIMEOUT = 90000; +var Snapshot = require('./snapshot.js'); /*! 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) + * @param {string=} name - The name of the subscription. */ /** * A Subscription object will give you access to your Cloud Pub/Sub @@ -73,7 +63,7 @@ var PUBSUB_API_TIMEOUT = 90000; * * - {module:pubsub#getSubscriptions} * - {module:pubsub/topic#getSubscriptions} - * - {module:pubsub/topic#subscribe} + * - {module:pubsub/topic#createSubscription} * * Subscription objects may be created directly with: * @@ -87,6 +77,20 @@ var PUBSUB_API_TIMEOUT = 90000; * @alias module:pubsub/subscription * @constructor * + * @param {object=} options - See a + * [Subscription resource](https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions) + * @param {object} options.flowControl - Flow control configurations for + * receiving messages. Note that these options do not persist across + * subscription instances. + * @param {number} options.flowControl.maxBytes - The maximum number of bytes + * in un-acked messages to allow before the subscription pauses incoming + * messages. Defaults to 20% of free memory. + * @param {number} options.flowControl.maxMessages - The maximum number of + * un-acked messages to allow before the subscription pauses incoming + * messages. Default: Infinity. + * @param {number} options.maxConnections - Use this to limit the number of + * connections to be used when sending and receiving messages. Default: 5. + * * @example * //- * // From {module:pubsub#getSubscriptions}: @@ -104,10 +108,10 @@ var PUBSUB_API_TIMEOUT = 90000; * }); * * //- - * // From {module:pubsub/topic#subscribe}: + * // From {module:pubsub/topic#createSubscription}: * //- * var topic = pubsub.topic('my-topic'); - * topic.subscribe('new-subscription', function(err, subscription) { + * topic.createSubscription('new-subscription', function(err, subscription) { * // `subscription` is a Subscription object. * }); * @@ -137,166 +141,52 @@ var PUBSUB_API_TIMEOUT = 90000; * // message.timestamp = Timestamp when Pub/Sub received the message. * * // Ack the message: - * // message.ack(callback); + * // message.ack(); * - * // 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(); + * // This doesn't ack the message, but allows more messages to be retrieved + * // if your limit was hit or if you don't want to ack the message. + * // message.nack(); * } * 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_(); +function Subscription(pubsub, name, options) { + options = options || {}; + + this.pubsub = pubsub; + this.projectId = pubsub.projectId; + this.request = pubsub.request.bind(pubsub); + this.histogram = new Histogram(); 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 - } - } - }; + this.connectionPool = null; + this.ackDeadline = 10000; + this.maxConnections = options.maxConnections || 5; - var config = { - parent: pubsub, - id: this.name, - methods: methods + this.inventory_ = { + lease: [], + ack: [], + nack: [], + bytes: 0 }; - 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; - } + this.flowControl = extend({ + maxBytes: os.freemem() * 0.2, + maxMessages: Infinity + }, options.flowControl); - commonGrpc.ServiceObject.call(this, config); - events.EventEmitter.call(this); + this.flushTimeoutHandle_ = null; + this.leaseTimeoutHandle_ = null; + this.userClosed_ = false; - 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; + events.EventEmitter.call(this); 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; + if (options.topic) { + this.create = pubsub.createSubscription.bind(pubsub, options.topic, name); } /** @@ -338,54 +228,7 @@ function Subscription(pubsub, options) { 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, enc) { - var innerMessage = msg.message; - var message = { - ackId: msg.ackId - }; - - if (innerMessage) { - message.id = innerMessage.messageId; - - if (innerMessage.data) { - if (enc === 'base64') { - // Prevent decoding and then re-encoding to base64. - message.data = innerMessage.data; - } else { - message.data = Buffer.from(innerMessage.data, 'base64').toString(enc); - - 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; -}; +util.inherits(Subscription, events.EventEmitter); /** * Format the name of a subscription. A subscription's full name is in the @@ -403,90 +246,121 @@ Subscription.formatName_ = function(projectId, name) { }; /** - * Generate a random name to use for a name-less subscription. + * Acks the provided message. If the connection pool is absent, it will be + * placed in an internal queue and sent out after 1 second or if the pool is + * re-opened before the timeout hits. * * @private + * + * @param {object} message - The message object. */ -Subscription.generateName_ = function() { - return 'autogenerated-' + uuid.v4(); +Subscription.prototype.ack_ = function(message) { + this.breakLease_(message); + this.histogram.add(Date.now() - message.received); + + if (!this.connectionPool || !this.connectionPool.isConnected()) { + this.inventory_.ack.push(message.ackId); + this.setFlushTimeout_(); + return; + } + + var self = this; + + this.connectionPool.acquire(message.connectionId, function(err, connection) { + if (err) { + self.emit('error', err); + return; + } + + connection.write({ ackIds: [message.ackId] }); + }); }; /** - * Acknowledge to the backend that the message was retrieved. You must provide - * either a single ackId or an array of ackIds. + * Breaks the lease on a message. Essentially this means we no longer treat the + * message as being un-acked and count it towards the flow control limits. * - * @resource [Subscriptions: acknowledge API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/acknowledge} + * If the pool was previously paused and we freed up space, we'll continue to + * recieve messages. * - * @throws {Error} If at least one ackId is not provided. + * @private + * + * @param {object} message - The message object. + */ +Subscription.prototype.breakLease_ = function(message) { + var messageIndex = this.inventory_.lease.indexOf(message.ackId); + + this.inventory_.lease.splice(messageIndex, 1); + this.inventory_.bytes -= message.length; + + if (this.connectionPool) { + if (this.connectionPool.isPaused && !this.hasMaxMessages_()) { + this.connectionPool.resume(); + } + } + + if (!this.inventory_.lease.length) { + clearTimeout(this.leaseTimeoutHandle_); + this.leaseTimeoutHandle_ = null; + } +}; + +/** + * Closes the subscription, once this is called you will no longer receive + * message events unless you add a new message listener. * - * @param {string|string[]} ackIds - An ackId or array of ackIds. - * @param {options=} options - Configuration object. - * @param {number} options.timeout - Set a maximum amount of time in - * milliseconds before giving up if no response is received. * @param {function=} callback - The callback function. + * @param {?error} callback.err - An error returned while closing the + * Subscription. * * @example - * var ackId = 'ePHEESyhuE8e...'; - * - * subscription.ack(ackId, function(err, apiResponse) {}); + * subscription.close(function(err) { + * if (err) { + * // Error handling omitted. + * } + * }); * * //- * // If the callback is omitted, we'll return a Promise. * //- - * subscription.ack(ackId).then(function(data) { - * var apiResponse = data[0]; - * }); + * subscription.close().then(function() {}); */ -Subscription.prototype.ack = function(ackIds, options, 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('')); - } +Subscription.prototype.close = function(callback) { + this.userClosed_ = true; - if (is.fn(options)) { - callback = options; - options = {}; - } + clearTimeout(this.leaseTimeoutHandle_); + this.leaseTimeoutHandle_ = null; - options = options || {}; - callback = callback || common.util.noop; + clearTimeout(this.flushTimeoutHandle_); + this.flushTimeoutHandle_ = null; - var protoOpts = { - service: 'Subscriber', - method: 'acknowledge' - }; + this.flushQueues_(); + this.closeConnection_(callback); +}; - if (options && is.number(options.timeout)) { - protoOpts.timeout = options.timeout; +/** + * Closes the connection pool. + * + * @private + * + * @param {function=} callback - The callback function. + * @param {?error} err - An error returned from this request. + */ +Subscription.prototype.closeConnection_ = function(callback) { + if (this.connectionPool) { + this.connectionPool.close(callback || common.util.noop); + this.connectionPool = null; + } else if (is.fn(callback)) { + setImmediate(callback); } - - var reqOpts = { - subscription: this.name, - ackIds: ackIds - }; - - this.parent.request(protoOpts, reqOpts, function(err, resp) { - if (!err) { - ackIds.forEach(function(ackId) { - delete self.inProgressAckIds[ackId]; - }); - - self.refreshPausedStatus_(); - } - - callback(err, resp); - }); }; /** * Create a snapshot with the given name. * * @param {string} name - Name of the snapshot. + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {function=} callback - The callback function. * @param {?error} callback.err - An error from the API call, may be null. * @param {module:pubsub/snapshot} callback.snapshot - The newly created @@ -511,72 +385,49 @@ Subscription.prototype.ack = function(ackIds, options, callback) { * var apiResponse = data[1]; * }); */ -Subscription.prototype.createSnapshot = function(name, callback) { +Subscription.prototype.createSnapshot = function(name, gaxOpts, callback) { var self = this; if (!is.string(name)) { throw new Error('A name is required to create a snapshot.'); } - var protoOpts = { - service: 'Subscriber', - method: 'createSnapshot' - }; + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } + + var snapshot = self.snapshot(name); var reqOpts = { - name: Snapshot.formatName_(this.parent.projectId, name), + name: snapshot.name, subscription: this.name }; - this.parent.request(protoOpts, reqOpts, function(err, resp) { + this.request({ + client: 'subscriberClient', + method: 'createSnapshot', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function(err, resp) { if (err) { callback(err, null, resp); return; } - var snapshot = self.snapshot(name); snapshot.metadata = resp; - callback(null, snapshot, 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 {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {function=} callback - The callback function. * @param {?error} callback.err - An error returned while making this * request. @@ -592,265 +443,277 @@ Subscription.prototype.decorateMessage_ = function(message) { * var apiResponse = data[0]; * }); */ -Subscription.prototype.delete = function(callback) { +Subscription.prototype.delete = function(gaxOpts, callback) { var self = this; - callback = callback || common.util.noop; + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } - var protoOpts = { - service: 'Subscriber', - method: 'deleteSubscription' - }; + callback = callback || common.util.noop; var reqOpts = { subscription: this.name }; - this.parent.request(protoOpts, reqOpts, function(err, resp) { - if (err) { - callback(err, resp); - return; + this.request({ + client: 'subscriberClient', + method: 'deleteSubscription', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function(err, resp) { + if (!err) { + self.removeAllListeners(); + self.close(); } - self.closed = true; - self.removeAllListeners(); - - callback(null, resp); + callback(err, 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} + * Check if a subscription exists. * - * @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. + * @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) {}); + * * //- - * // Pull all available messages. + * // If the callback is omitted, we'll return a Promise. * //- - * 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. - * // }, - * // // ... - * // ] + * subscription.exists().then(function(data) { + * var exists = data[0]; * }); + */ +Subscription.prototype.exists = function(callback) { + this.getMetadata(function(err) { + if (!err) { + callback(null, true); + return; + } + + if (err.code === 5) { + callback(null, false); + return; + } + + callback(err); + }); +}; + +/** + * Flushes internal queues. These can build up if a user attempts to ack/nack + * while there is no connection pool (e.g. after they called close). * - * //- - * // Pull a single message. - * //- - * var opts = { - * maxResults: 1 - * }; + * Typically this will only be called either after a timeout or when a + * connection is re-opened. * - * subscription.pull(opts, function(err, messages, apiResponse) {}); + * Any errors that occur will be emitted via `error` events. * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * subscription.pull(opts).then(function(data) { - * var messages = data[0]; - * var apiResponse = data[1]; - * }); + * @private */ -Subscription.prototype.pull = function(options, callback) { +Subscription.prototype.flushQueues_ = function() { var self = this; - var MAX_EVENTS_LIMIT = 1000; - if (!callback) { - callback = options; - options = {}; - } + var acks = this.inventory_.ack; + var nacks = this.inventory_.nack; - if (!is.number(options.maxResults)) { - options.maxResults = MAX_EVENTS_LIMIT; + if (!acks.length && !nacks.length) { + return; } - var protoOpts = { - service: 'Subscriber', - method: 'pull', - timeout: this.timeout - }; - - var reqOpts = { - subscription: this.name, - returnImmediately: !!options.returnImmediately, - maxMessages: options.maxResults - }; + if (this.connectionPool) { + this.connectionPool.acquire(function(err, connection) { + if (err) { + self.emit('error', err); + return; + } - this.activeRequest_ = this.parent.request(protoOpts, reqOpts, function(err) { - self.activeRequest_ = null; + var reqOpts = {}; - var resp = arguments[1]; + if (acks.length) { + reqOpts.ackIds = acks; + } - if (err) { - if (err.code === 504) { - // Simulate a server timeout where no messages were received. - resp = { - receivedMessages: [] - }; - } else { - callback(err, null, resp); - return; + if (nacks.length) { + reqOpts.modifyDeadlineAckIds = nacks; + reqOpts.modifyDeadlineSeconds = Array(nacks.length).fill(0); } - } - var messages = arrify(resp.receivedMessages) - .map(function(msg) { - return Subscription.formatMessage_(msg, self.encoding); - }) - .map(self.decorateMessage_.bind(self)); + connection.write(reqOpts); - self.refreshPausedStatus_(); + self.inventory_.ack = []; + self.inventory_.nack = []; + }); + return; + } - if (self.autoAck && messages.length !== 0) { - var ackIds = messages.map(prop('ackId')); + if (acks.length) { + this.request({ + client: 'subscriberClient', + method: 'acknowledge', + reqOpts: { + subscription: this.name, + ackIds: acks + } + }, function(err) { + if (err) { + self.emit('error', err); + } else { + self.inventory_.ack = []; + } + }); + } - self.ack(ackIds, function(err) { - callback(err, messages, resp); - }); - } else { - callback(null, messages, resp); - } - }); + if (nacks.length) { + this.request({ + client: 'subscriberClient', + method: 'modifyAckDeadline', + reqOpts: { + subscription: this.name, + ackIds: nacks, + ackDeadlineSeconds: 0 + } + }, function(err) { + if (err) { + self.emit('error', err); + } else { + self.inventory_.nack = []; + } + }); + } }; /** - * Seeks an existing subscription to a point in time or a given snapshot. + * Get a subscription if it exists. * - * @param {string|date} snapshot - The point to seek to. This will accept the - * name of the snapshot or a Date object. - * @param {function} callback - The callback function. - * @param {?error} callback.err - An error from the API call, may be null. - * @param {object} callback.apiResponse - The full API response from the - * service. + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {boolean} gaxOpts.autoCreate - Automatically create the subscription + * does not already exist. Default: false. * * @example - * var callback = function(err, resp) { - * if (!err) { - * // Seek was successful. - * } - * }; - * - * subscription.seek('my-snapshot', callback); + * subscription.get(function(err, subscription, apiResponse) { + * // The `subscription` data has been populated. + * }); * * //- - * // Alternatively, to specify a certain point in time, you can provide a Date - * // object. + * // If the callback is omitted, we'll return a Promise. * //- - * var date = new Date('October 21 2015'); - * - * subscription.seek(date, callback); + * subscription.get().then(function(data) { + * var subscription = data[0]; + * var apiResponse = data[1]; + * }); */ -Subscription.prototype.seek = function(snapshot, callback) { - var protoOpts = { - service: 'Subscriber', - method: 'seek' - }; - - var reqOpts = { - subscription: this.name - }; +Subscription.prototype.get = function(gaxOpts, callback) { + var self = this; - if (is.string(snapshot)) { - reqOpts.snapshot = Snapshot.formatName_(this.parent.projectId, snapshot); - } else if (is.date(snapshot)) { - reqOpts.time = { - seconds: Math.floor(snapshot.getTime() / 1000), - nanos: snapshot.getMilliseconds() * 1e6 - }; - } else { - throw new Error('Either a snapshot name or Date is needed to seek to.'); + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; } - this.parent.request(protoOpts, reqOpts, callback); + var autoCreate = !!gaxOpts.autoCreate && is.fn(this.create); + delete gaxOpts.autoCreate; + + this.getMetadata(gaxOpts, function(err, apiResponse) { + if (!err) { + callback(null, self, apiResponse); + return; + } + + if (err.code !== 5 || !autoCreate) { + callback(err, null, apiResponse); + return; + } + + self.create(gaxOpts, callback); + }); }; /** - * 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. + * Fetches the subscriptions metadata. * - * @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. + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @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 - * var options = { - * ackIds: ['abc'], - * seconds: 10 // Expire in 10 seconds from call. - * }; - * - * subscription.setAckDeadline(options, function(err, apiResponse) {}); + * subscription.getMetadata(function(err, apiResponse) { + * if (err) { + * // Error handling omitted. + * } + * }); * * //- * // If the callback is omitted, we'll return a Promise. * //- - * subscription.setAckDeadline(options).then(function(data) { + * subscription.getMetadata().then(function(data) { * var apiResponse = data[0]; * }); */ -Subscription.prototype.setAckDeadline = function(options, callback) { - callback = callback || common.util.noop; +Subscription.prototype.getMetadata = function(gaxOpts, callback) { + var self = this; - var protoOpts = { - service: 'Subscriber', - method: 'modifyAckDeadline' - }; + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } var reqOpts = { - subscription: this.name, - ackIds: arrify(options.ackIds), - ackDeadlineSeconds: options.seconds + subscription: this.name }; - this.parent.request(protoOpts, reqOpts, function(err, resp) { - callback(err, resp); + this.request({ + client: 'subscriberClient', + method: 'getSubscription', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function(err, apiResponse) { + if (!err) { + self.metadata = apiResponse; + } + + callback(err, apiResponse); }); }; /** - * Create a Snapshot object. See {module:pubsub/subscription#createSnapshot} to - * create a snapshot. + * Checks to see if this Subscription has hit any of the flow control + * thresholds. * - * @throws {Error} If a name is not provided. + * @private * - * @param {string} name - The name of the snapshot. - * @return {module:pubsub/snapshot} + * @return {boolean} + */ +Subscription.prototype.hasMaxMessages_ = function() { + return this.inventory_.lease.length >= this.flowControl.maxMessages || + this.inventory_.bytes >= this.flowControl.maxBytes; +}; + +/** + * Leases a message. This will add the message to our inventory list and then + * modifiy the ack deadline for the user if they exceed the specified ack + * deadline. * - * @example - * var snapshot = subscription.snapshot('my-snapshot'); + * @private + * + * @param {object} message - The message object. */ -Subscription.prototype.snapshot = function(name) { - return this.parent.snapshot.call(this, name); +Subscription.prototype.leaseMessage_ = function(message) { + this.inventory_.lease.push(message.ackId); + this.inventory_.bytes += message.length; + this.setLeaseTimeout_(); + + return message; }; /** @@ -872,92 +735,332 @@ Subscription.prototype.listenForEvents_ = function() { this.on('newListener', function(event) { if (event === 'message') { self.messageListeners++; - if (self.closed) { - self.closed = false; - self.startPulling_(); + + if (!self.connectionPool) { + self.userClosed_ = false; + self.openConnection_(); } } }); this.on('removeListener', function(event) { if (event === 'message' && --self.messageListeners === 0) { - self.closed = true; + self.closeConnection_(); + } + }); +}; - if (self.activeRequest_ && self.activeRequest_.abort) { - self.activeRequest_.abort(); - } +/** + * Modify the push config for the subscription. + * + * @param {object} config - The push config. + * @param {string} config.pushEndpoint - A URL locating the endpoint to which + * messages should be published. + * @param {object} config.attributes - [PushConfig attributes](https://cloud.google.com/pubsub/docs/reference/rpc/google.pubsub.v1#google.pubsub.v1.PushConfig). + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error from the API call. + * @param {object} callback.apiResponse - The full API response from the + * service. + * + * @example + * var pushConfig = { + * pushEndpoint: 'https://mydomain.com/push', + * attributes: { + * key: 'value' + * } + * }; + * + * subscription.modifyPushConfig(pushConfig, function(err, apiResponse) { + * if (err) { + * // Error handling omitted. + * } + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * subscription.modifyPushConfig(pushConfig).then(function(data) { + * var apiResponse = data[0]; + * }); + */ +Subscription.prototype.modifyPushConfig = function(config, gaxOpts, callback) { + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } + + var reqOpts = { + subscription: this.name, + pushConfig: config + }; + + this.request({ + client: 'subscriberClient', + method: 'modifyPushConfig', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, callback); +}; + +/** + * Nacks the provided message. If the connection pool is absent, it will be + * placed in an internal queue and sent out after 1 second or if the pool is + * re-opened before the timeout hits. + * + * @private + * + * @param {object} message - The message object. + */ +Subscription.prototype.nack_ = function(message) { + this.breakLease_(message); + + if (!this.connectionPool || !this.connectionPool.isConnected()) { + this.inventory_.nack.push(message.ackId); + this.setFlushTimeout_(); + return; + } + + var self = this; + + this.connectionPool.acquire(message.connectionId, function(err, connection) { + if (err) { + self.emit('error', err); + return; } + + connection.write({ + modifyDeadlineAckIds: [message.ackId], + modifyDeadlineSeconds: [0] + }); }); }; /** - * 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. + * Opens the ConnectionPool. * - * 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.openConnection_ = function() { + var self = this; + var pool = this.connectionPool = new ConnectionPool(this); + + pool.on('error', function(err) { + self.emit('error', err); + }); + + pool.on('message', function(message) { + self.emit('message', self.leaseMessage_(message)); + + if (self.hasMaxMessages_() && !pool.isPaused) { + pool.pause(); + } + }); + + pool.once('connected', function() { + clearTimeout(self.flushTimeoutHandle_); + self.flushTimeoutHandle_ = null; + self.flushQueues_(); + }); +}; + +/** + * Modifies the ack deadline on messages that have yet to be acked. We update + * the ack deadline to the 99th percentile of known ack times. * * @private */ -Subscription.prototype.refreshPausedStatus_ = function() { - var isCurrentlyPaused = this.paused; - var inProgress = Object.keys(this.inProgressAckIds).length; +Subscription.prototype.renewLeases_ = function() { + var self = this; - this.paused = inProgress >= this.maxInProgress; + this.leaseTimeoutHandle_ = null; - if (isCurrentlyPaused && !this.paused && this.messageListeners > 0) { - this.startPulling_(); + if (!this.inventory_.lease.length) { + return; } + + var ackIds = this.inventory_.lease; + this.ackDeadline = this.histogram.percentile(99); + var ackDeadlineSeconds = this.ackDeadline / 1000; + + if (this.connectionPool) { + this.connectionPool.acquire(function(err, connection) { + if (err) { + self.emit('error', err); + return; + } + + connection.write({ + modifyDeadlineAckIds: ackIds, + modifyDeadlineSeconds: Array(ackIds.length).fill(ackDeadlineSeconds) + }); + }); + } else { + this.request({ + client: 'subscriberClient', + method: 'modifyAckDeadline', + reqOpts: { + subscription: self.name, + ackIds: ackIds, + ackDeadlineSeconds: ackDeadlineSeconds + } + }, function(err) { + if (err) { + self.emit('error', err); + } + }); + } + + this.setLeaseTimeout_(); }; /** - * 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. + * Seeks an existing subscription to a point in time or a given snapshot. * - * If messages are received, they are emitted on the `message` event. + * @param {string|date} snapshot - The point to seek to. This will accept the + * name of the snapshot or a Date object. + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error from the API call, may be null. + * @param {object} callback.apiResponse - The full API response from the + * service. * - * Note: This method is automatically called once a message event handler is - * assigned to the description. + * @example + * var callback = function(err, resp) { + * if (!err) { + * // Seek was successful. + * } + * }; * - * To stop pulling, see {module:pubsub/subscription#close}. + * subscription.seek('my-snapshot', callback); * - * @private + * //- + * // Alternatively, to specify a certain point in time, you can provide a Date + * // object. + * //- + * var date = new Date('October 21 2015'); * - * @example - * subscription.startPulling_(); + * subscription.seek(date, callback); */ -Subscription.prototype.startPulling_ = function() { - var self = this; +Subscription.prototype.seek = function(snapshot, gaxOpts, callback) { + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } - if (this.closed || this.paused) { + var reqOpts = { + subscription: this.name + }; + + if (is.string(snapshot)) { + reqOpts.snapshot = Snapshot.formatName_(this.pubsub.projectId, snapshot); + } else if (is.date(snapshot)) { + reqOpts.time = snapshot; + } else { + throw new Error('Either a snapshot name or Date is needed to seek to.'); + } + + this.request({ + client: 'subscriberClient', + method: 'seek', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, callback); +}; + +/** + * Sets a timeout to flush any acks/nacks that have been made since the pool has + * closed. + * + * @private + */ +Subscription.prototype.setFlushTimeout_ = function() { + if (this.flushTimeoutHandle_) { return; } - var maxResults; + this.flushTimeoutHandle_ = setTimeout(this.flushQueues_.bind(this), 1000); +}; - if (this.maxInProgress < Infinity) { - maxResults = this.maxInProgress - Object.keys(this.inProgressAckIds).length; +/** + * Sets a timeout to modify the ack deadlines for any unacked/unnacked messages, + * renewing their lease. + * + * @private + */ +Subscription.prototype.setLeaseTimeout_ = function() { + if (this.leaseTimeoutHandle_) { + return; } - this.pull({ - returnImmediately: false, - maxResults: maxResults - }, function(err, messages, apiResponse) { - if (err) { - self.emit('error', err, apiResponse); - } + var timeout = Math.random() * this.ackDeadline * 0.9; + this.leaseTimeoutHandle_ = setTimeout(this.renewLeases_.bind(this), timeout); +}; - if (messages) { - messages.forEach(function(message) { - self.emit('message', message, apiResponse); - }); - } +/** + * Update the subscription object. + * + * @param {object} metadata - The subscription metadata. + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {function=} callback - The callback function. + * @param {?error} callback.err - An error from the API call. + * @param {object} callback.apiResponse - The full API response from the + * service. + * + * @example + * var metadata = { + * key: 'value' + * }; + * + * subscription.setMetadata(metadata, function(err, apiResponse) { + * if (err) { + * // Error handling omitted. + * } + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * subscription.setMetadata(metadata).then(function(data) { + * var apiResponse = data[0]; + * }); + */ +Subscription.prototype.setMetadata = function(metadata, gaxOpts, callback) { + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } - setTimeout(self.startPulling_.bind(self), self.interval); - }); + var reqOpts = { + subscription: this.name, + updateMask: metadata + }; + + this.request({ + client: 'subscriberClient', + method: 'updateSubscription', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, callback); +}; + +/** + * Create a Snapshot object. See {module:pubsub/subscription#createSnapshot} to + * create a snapshot. + * + * @throws {Error} If a name is not provided. + * + * @param {string} name - The name of the snapshot. + * @return {module:pubsub/snapshot} + * + * @example + * var snapshot = subscription.snapshot('my-snapshot'); + */ +Subscription.prototype.snapshot = function(name) { + return this.pubsub.snapshot.call(this, name); }; /*! Developer Documentation diff --git a/packages/pubsub/src/topic.js b/packages/pubsub/src/topic.js index 812a6ce92bf..6fe37cb300b 100644 --- a/packages/pubsub/src/topic.js +++ b/packages/pubsub/src/topic.js @@ -1,5 +1,5 @@ /*! - * Copyright 2014 Google Inc. All Rights Reserved. + * Copyright 2017 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. @@ -20,12 +20,9 @@ 'use strict'; -var arrify = require('arrify'); var common = require('@google-cloud/common'); -var commonGrpc = require('@google-cloud/common-grpc'); var extend = require('extend'); var is = require('is'); -var util = require('util'); /** * @type {module:pubsub/iam} @@ -33,6 +30,12 @@ var util = require('util'); */ var IAM = require('./iam.js'); +/** + * @type {module:pubsub/publisher} + * @private + */ +var Publisher = require('./publisher.js'); + /*! Developer Documentation * * @param {module:pubsub} pubsub - PubSub object. @@ -49,143 +52,8 @@ var IAM = require('./iam.js'); */ function Topic(pubsub, name) { this.name = Topic.formatName_(pubsub.projectId, name); - - var methods = { - /** - * Create a topic. - * - * @param {object=} config - See {module:pubsub#createTopic}. - * - * @example - * topic.create(function(err, topic, apiResponse) { - * if (!err) { - * // The topic was created successfully. - * } - * }); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.create().then(function(data) { - * var topic = data[0]; - * var apiResponse = data[1]; - * }); - */ - create: true, - - /** - * Delete the topic. This will not delete subscriptions to this topic. - * - * @resource [Topics: delete API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/delete} - * - * @param {function=} callback - The callback function. - * - * @example - * topic.delete(function(err, apiResponse) {}); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.delete().then(function(data) { - * var apiResponse = data[0]; - * }); - */ - delete: { - protoOpts: { - service: 'Publisher', - method: 'deleteTopic' - }, - reqOpts: { - topic: this.name - } - }, - - /** - * Check if the topic exists. - * - * @param {function} callback - The callback function. - * @param {?error} callback.err - An error returned while making this - * request. - * @param {boolean} callback.exists - Whether the topic exists or not. - * - * @example - * topic.exists(function(err, exists) {}); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.exists().then(function(data) { - * var exists = data[0]; - * }); - */ - exists: true, - - /** - * Get a topic 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. - * - * @param {options=} options - Configuration object. - * @param {boolean} options.autoCreate - Automatically create the object if - * it does not exist. Default: `false` - * - * @example - * topic.get(function(err, topic, apiResponse) { - * // `topic.metadata` has been populated. - * }); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.get().then(function(data) { - * var topic = data[0]; - * var apiResponse = data[1]; - * }); - */ - get: true, - - /** - * Get the official representation of this topic from the API. - * - * @resource [Topics: get API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/get} - * - * @param {function} callback - The callback function. - * @param {?error} callback.err - An error returned while making this - * request. - * @param {object} callback.metadata - The metadata of the Topic. - * @param {object} callback.apiResponse - The full API response. - * - * @example - * topic.getMetadata(function(err, metadata, apiResponse) {}); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.getMetadata().then(function(data) { - * var metadata = data[0]; - * var apiResponse = data[1]; - * }); - */ - getMetadata: { - protoOpts: { - service: 'Publisher', - method: 'getTopic' - }, - reqOpts: { - topic: this.name - } - } - }; - - commonGrpc.ServiceObject.call(this, { - parent: pubsub, - id: this.name, - createMethod: pubsub.createTopic.bind(pubsub), - methods: methods - }); + this.pubsub = pubsub; + this.request = pubsub.request.bind(pubsub); /** * [IAM (Identity and Access Management)](https://cloud.google.com/pubsub/access_control) @@ -224,39 +92,275 @@ function Topic(pubsub, name) { this.iam = new IAM(pubsub, this.name); } -util.inherits(Topic, commonGrpc.ServiceObject); - /** - * Format a message object as the upstream API expects it. + * Format the name of a topic. A Topic's full name is in the format of + * 'projects/{projectId}/topics/{topicName}'. * * @private * - * @return {object} + * @return {string} */ -Topic.formatMessage_ = function(message) { - if (!(message.data instanceof Buffer)) { - message.data = new Buffer(JSON.stringify(message.data)); +Topic.formatName_ = function(projectId, name) { + // Simple check if the name is already formatted. + if (name.indexOf('/') > -1) { + return name; } + return 'projects/' + projectId + '/topics/' + name; +}; - message.data = message.data.toString('base64'); +/** + * Create a topic. + * + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {function=} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request. + * @param {module:pubsub/topic} callback.topic - The topic. + * @param {object} callback.apiResponse - The full API response. + * + * @example + * topic.create(function(err, topic, apiResponse) { + * if (!err) { + * // The topic was created successfully. + * } + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * topic.create().then(function(data) { + * var topic = data[0]; + * var apiResponse = data[1]; + * }); + */ +Topic.prototype.create = function(gaxOpts, callback) { + this.pubsub.createTopic(this.name, gaxOpts, callback); +}; - return message; +/** + * Create a subscription to this topic. + * + * @resource [Subscriptions: create API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/create} + * + * @throws {Error} If subscription name is omitted. + * + * @param {string=} name - The name of the subscription. + * @param {object=} options - See a + * [Subscription resource](https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions) + * @param {number} options.ackDeadline - The maximum time after receiving a + * message that you must ack a message before it is redelivered. + * @param {object} options.flowControl - Flow control configurations for + * receiving messages. + * @param {number} options.flowControl.maxBytes - The maximum number of bytes + * in un-acked messages to allow before the subscription pauses incoming + * messages. Defaults to 20% of free memory. + * @param {number} options.flowControl.maxMessages - The maximum number of + * un-acked messages to allow before the subscription pauses incoming + * messages. Default: Infinity. + * @param {object} options.gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {number|date} options.messageRetentionDuration - Set this to override + * the default duration of 7 days. This value is expected in seconds. + * Acceptable values are in the range of 10 minutes and 7 days. + * @param {string} options.pushEndpoint - A URL to a custom endpoint that + * messages should be pushed to. + * @param {boolean} options.retainAckedMessages - If set, acked messages are + * retained in the subscription's backlog for 7 days (unless overriden by + * `options.messageRetentionDuration`). Default: `false` + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this request. + * @param {module:pubsub/subscription} callback.subscription - The subscription. + * @param {object} callback.apiResponse - The full API response. + * + * @example + * var callback = function(err, subscription, apiResponse) {}; + * + * // Without specifying any options. + * topic.createSubscription('newMessages', callback); + * + * // With options. + * topic.createSubscription('newMessages', { + * ackDeadline: 90000 // 90 seconds + * }, callback); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * topic.createSubscription('newMessages').then(function(data) { + * var subscription = data[0]; + * var apiResponse = data[1]; + * }); + */ +Topic.prototype.createSubscription = function(name, options, callback) { + this.pubsub.createSubscription(this, name, options, callback); }; /** - * Format the name of a topic. A Topic's full name is in the format of - * 'projects/{projectId}/topics/{topicName}'. + * Delete the topic. This will not delete subscriptions to this topic. * - * @private + * @resource [Topics: delete API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/delete} * - * @return {string} + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {function=} callback - The callback function. + * + * @example + * topic.delete(function(err, apiResponse) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * topic.delete().then(function(data) { + * var apiResponse = data[0]; + * }); */ -Topic.formatName_ = function(projectId, name) { - // Simple check if the name is already formatted. - if (name.indexOf('/') > -1) { - return name; +Topic.prototype.delete = function(gaxOpts, callback) { + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; } - return 'projects/' + projectId + '/topics/' + name; + + callback = callback || common.util.noop; + + var reqOpts = { + topic: this.name + }; + + this.request({ + client: 'publisherClient', + method: 'deleteTopic', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, callback); +}; + +/** + * Check if a topic exists. + * + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this + * request. + * @param {boolean} callback.exists - Whether the topic exists or not. + * + * @example + * topic.exists(function(err, exists) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * topic.exists().then(function(data) { + * var exists = data[0]; + * }); + */ +Topic.prototype.exists = function(callback) { + this.getMetadata(function(err) { + if (!err) { + callback(null, true); + return; + } + + if (err.code === 5) { + callback(null, false); + return; + } + + callback(err); + }); +}; + +/** + * Get a topic if it exists. + * + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {boolean} gaxOpts.autoCreate - Automatically create the topic does not + * already exist. Default: false. + * + * @example + * topic.get(function(err, topic, apiResponse) { + * // The `topic` data has been populated. + * }); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * topic.get().then(function(data) { + * var topic = data[0]; + * var apiResponse = data[1]; + * }); + */ +Topic.prototype.get = function(gaxOpts, callback) { + var self = this; + + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } + + var autoCreate = !!gaxOpts.autoCreate; + delete gaxOpts.autoCreate; + + this.getMetadata(gaxOpts, function(err, apiResponse) { + if (!err) { + callback(null, self, apiResponse); + return; + } + + if (err.code !== 5 || !autoCreate) { + callback(err, null, apiResponse); + return; + } + + self.create(gaxOpts, callback); + }); +}; + +/** + * Get the official representation of this topic from the API. + * + * @resource [Topics: get API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/get} + * + * @param {object=} gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. + * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this + * request. + * @param {object} callback.apiResponse - The full API response. + * + * @example + * topic.getMetadata(function(err, apiResponse) {}); + * + * //- + * // If the callback is omitted, we'll return a Promise. + * //- + * topic.getMetadata().then(function(data) { + * var apiResponse = data[0]; + * }); + */ +Topic.prototype.getMetadata = function(gaxOpts, callback) { + var self = this; + + if (is.fn(gaxOpts)) { + callback = gaxOpts; + gaxOpts = {}; + } + + var reqOpts = { + topic: this.name + }; + + this.request({ + client: 'publisherClient', + method: 'getTopic', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function(err, apiResponse) { + if (!err) { + self.metadata = apiResponse; + } + + callback(err, apiResponse); + }); }; /** @@ -271,11 +375,15 @@ Topic.formatName_ = function(projectId, name) { * @param {object=} options - Configuration object. * @param {boolean} options.autoPaginate - Have pagination handled * automatically. Default: true. - * @param {number} options.maxApiCalls - Maximum number of API calls to make. - * @param {number} options.maxResults - Maximum number of results to return. + * @param {object} options.gaxOpts - Request configuration options, outlined + * here: https://googleapis.github.io/gax-nodejs/CallSettings.html. * @param {number} options.pageSize - Maximum number of results to return. * @param {string} options.pageToken - Page token. * @param {function} callback - The callback function. + * @param {?error} callback.err - An error returned while making this + * request. + * @param {module:pubsub/subscription[]} callback.subscriptions - List of + * subscriptions. * * @example * topic.getSubscriptions(function(err, subscriptions) { @@ -288,21 +396,6 @@ Topic.formatName_ = function(projectId, name) { * }, callback); * * //- - * // To control how many API requests are made and page through the results - * // manually, set `autoPaginate` to `false`. - * //- - * function callback(err, subscriptions, nextQuery, apiResponse) { - * if (nextQuery) { - * // More results exist. - * topic.getSubscriptions(nextQuery, callback); - * } - * } - * - * topic.getSubscriptions({ - * autoPaginate: false - * }, callback); - * - * //- * // If the callback is omitted, we'll return a Promise. * //- * topic.getSubscriptions().then(function(data) { @@ -310,15 +403,41 @@ Topic.formatName_ = function(projectId, name) { * }); */ Topic.prototype.getSubscriptions = function(options, callback) { + var self = this; + if (is.fn(options)) { callback = options; options = {}; } - options = options || {}; - options.topic = this; + var reqOpts = extend({ + topic: this.name + }, options); + + delete reqOpts.gaxOpts; + delete reqOpts.autoPaginate; + + var gaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + this.request({ + client: 'publisherClient', + method: 'listTopicSubscriptions', + reqOpts: reqOpts, + gaxOpts: gaxOpts + }, function() { + var subscriptions = arguments[1]; + + if (subscriptions) { + arguments[1] = subscriptions.map(function(sub) { + // ListTopicSubscriptions only returns sub names + return self.subscription(sub); + }); + } - return this.parent.getSubscriptions(options, callback); + callback.apply(null, arguments); + }); }; /** @@ -348,205 +467,32 @@ Topic.prototype.getSubscriptions = function(options, callback) { * this.end(); * }); */ -Topic.prototype.getSubscriptionsStream = function(options) { - options = options || {}; - options.topic = this; - - return this.parent.getSubscriptionsStream(options); -}; +Topic.prototype.getSubscriptionsStream = + common.paginator.streamify('getSubscriptions'); /** - * Publish the provided message or array of messages. On success, an array of - * messageIds is returned in the response. - * - * @resource [Topics: publish API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.topics/publish} + * Creates a Publisher object that allows you to publish messages to this topic. * - * @throws {Error} If no message is provided. - * - * @param {*|*[]} message - The message(s) to publish. If you need to - * provide attributes for the message, you must enable `options.raw`, then - * box your message in to an object with a `data` and `attributes` property. - * `data` will be the raw message value you want to publish, and - * `attributes` is a key/value pair of attributes to apply to the message. - * All messages not provided as `Buffer` will be published in JSON format. - * If your receiving end uses another library, make sure it parses the - * message properly. * @param {object=} options - Configuration object. - * @param {boolean} options.raw - Enable if you require setting attributes on - * your messages. - * @param {number} options.timeout - Set a maximum amount of time in - * milliseconds before giving up if no response is received. - * @param {function=} callback - The callback function. + * @param {object} options.batching - Batching settings. + * @param {number} options.batching.maxBytes - The maximum number of bytes to + * buffer before sending a payload. + * @param {number} options.batching.maxMessages - The maximum number of messages + * to buffer before sending a payload. + * @param {number} options.batching.maxMilliseconds - The maximum duration to + * wait before sending a payload. * * @example - * topic.publish('Hello, world!', function(err, messageIds, apiResponse) {}); - * - * //- - * // You can also publish a JSON object. - * //- - * var registerMessage = { - * userId: 3, - * name: 'Stephen', - * event: 'new user' - * }; - * - * topic.publish(registerMessage, function(err, messageIds, apiResponse) {}); - * - * //- - * // You can publish a batch of messages at once by supplying an array. - * //- - * var purchaseMessage = { - * data: { - * userId: 3, - * product: 'computer', - * event: 'purchase' - * } - * }; + * var publisher = topic.publisher(); * - * topic.publish([ - * registerMessage, - * purchaseMessage - * ], function(err, messageIds, apiResponse) {}); - * - * //- - * // Set attributes with your message. - * //- - * var message = { - * data: { - * userId: 3, - * product: 'book', - * event: 'rent' - * }, - * attributes: { - * key: 'value', - * hello: 'world' + * publisher.publish(new Buffer('Hello, world!'), function(err, messageId) { + * if (err) { + * // Error handling omitted. * } - * }; - * - * var options = { - * raw: true - * }; - * - * topic.publish(message, options, function(err, messageIds, apiResponse) {}); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.publish(message).then(function(data) { - * var messageIds = data[0]; - * var apiResponse = data[1]; - * }); - * - */ -Topic.prototype.publish = function(messages, options, callback) { - messages = arrify(messages); - - if (is.fn(options)) { - callback = options; - options = {}; - } - - options = options || {}; - callback = callback || common.util.noop; - - if (messages.length === 0) { - throw new Error('Cannot publish without a message.'); - } - - var protoOpts = { - service: 'Publisher', - method: 'publish', - }; - - if (is.number(options.timeout)) { - protoOpts.timeout = options.timeout; - } - - var reqOpts = { - topic: this.name, - messages: messages - .map(function(message) { - if (is.object(message)) { - message = extend(true, {}, message); - } - return options.raw ? message : { data: message }; - }) - .map(Topic.formatMessage_) - }; - - this.parent.request(protoOpts, reqOpts, function(err, result) { - if (err) { - callback(err, null, result); - return; - } - - callback(null, arrify(result.messageIds), result); - }); -}; - -/** - * Create a subscription to this topic. - * - * All generated subscription names share a common prefix, `autogenerated-`. - * - * @resource [Subscriptions: create API Documentation]{@link https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions/create} - * - * @param {string=} subName - The name of the subscription. If a name is not - * provided, a random subscription name will be generated and created. - * @param {object=} options - See a - * [Subscription resource](https://cloud.google.com/pubsub/docs/reference/rest/v1/projects.subscriptions) - * @param {number} options.ackDeadlineSeconds - The maximum time after - * receiving a message that you must ack a message before it is redelivered. - * @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 {number} options.maxInProgress - Maximum messages to consume - * simultaneously. - * @param {number|date} options.messageRetentionDuration - Set this to override - * the default duration of 7 days. This value is expected in seconds. - * Acceptable values are in the range of 10 minutes and 7 days. - * @param {string} options.pushEndpoint - A URL to a custom endpoint that - * messages should be pushed to. - * @param {boolean} options.retainAckedMessages - If set, acked messages are - * retained in the subscription's backlog for 7 days (unless overriden by - * `options.messageRetentionDuration`). Default: `false` - * @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. - * @param {function} callback - The callback function. - * - * @example - * // Without specifying any options. - * topic.subscribe('newMessages', function(err, subscription, apiResponse) {}); - * - * //- - * // Omit the name to have one generated automatically. All generated names - * // share a common prefix, `autogenerated-`. - * //- - * topic.subscribe(function(err, subscription, apiResponse) { - * // subscription.name = The generated name. - * }); - * - * // With options. - * topic.subscribe('newMessages', { - * ackDeadlineSeconds: 90, - * autoAck: true, - * interval: 30 - * }, function(err, subscription, apiResponse) {}); - * - * //- - * // If the callback is omitted, we'll return a Promise. - * //- - * topic.subscribe('newMessages').then(function(data) { - * var subscription = data[0]; - * var apiResponse = data[1]; * }); */ -Topic.prototype.subscribe = function(subName, options, callback) { - this.parent.subscribe(this, subName, options, callback); +Topic.prototype.publisher = function(options) { + return new Publisher(this, options); }; /** @@ -554,12 +500,21 @@ Topic.prototype.subscribe = function(subName, options, callback) { * requests. You will receive a {module:pubsub/subscription} object, * which will allow you to interact with a subscription. * + * @throws {Error} If subscription name is omitted. + * * @param {string} name - Name of the subscription. * @param {object=} options - Configuration object. - * @param {boolean=} options.autoAck - Automatically acknowledge the message - * once it's pulled. - * @param {number=} options.interval - Interval in milliseconds to check for new - * messages. + * @param {object} options.flowControl - Flow control configurations for + * receiving messages. Note that these options do not persist across + * subscription instances. + * @param {number} options.flowControl.maxBytes - The maximum number of bytes + * in un-acked messages to allow before the subscription pauses incoming + * messages. Defaults to 20% of free memory. + * @param {number} options.flowControl.maxMessages - The maximum number of + * un-acked messages to allow before the subscription pauses incoming + * messages. Default: Infinity. + * @param {number} options.maxConnections - Use this to limit the number of + * connections to be used when sending and receiving messages. Default: 5. * @return {module:pubsub/subscription} * * @example @@ -572,23 +527,34 @@ Topic.prototype.subscribe = function(subName, options, callback) { * // 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. + * // message.publishTime = Timestamp when Pub/Sub received the message. * }); */ Topic.prototype.subscription = function(name, options) { options = options || {}; options.topic = this; - return this.parent.subscription(name, options); + return this.pubsub.subscription(name, options); }; +/*! Developer Documentation + * + * These methods can be agto-paginated. + */ +common.paginator.extend(Topic, [ + 'getSubscriptions' +]); + /*! Developer Documentation * * All async methods (except for streams) will return a Promise in the event * that a callback is omitted. */ common.util.promisifyAll(Topic, { - exclude: ['subscription'] + exclude: [ + 'publisher', + 'subscription' + ] }); module.exports = Topic; diff --git a/packages/pubsub/src/v1/subscriber_client_config.json b/packages/pubsub/src/v1/subscriber_client_config.json index 2c8efffba81..a8e53152d79 100644 --- a/packages/pubsub/src/v1/subscriber_client_config.json +++ b/packages/pubsub/src/v1/subscriber_client_config.json @@ -77,7 +77,7 @@ "retry_params_name": "messaging" }, "StreamingPull": { - "timeout_millis": 60000, + "timeout_millis": 900000, "retry_codes_name": "pull", "retry_params_name": "messaging" }, diff --git a/packages/pubsub/system-test/pubsub.js b/packages/pubsub/system-test/pubsub.js index 01e0966516c..895eb6d6443 100644 --- a/packages/pubsub/system-test/pubsub.js +++ b/packages/pubsub/system-test/pubsub.js @@ -64,6 +64,7 @@ describe('pubsub', function() { options = options || {}; var topic = pubsub.topic(generateTopicName()); + var publisher = topic.publisher(); var subscription = topic.subscription(generateSubName()); async.series([ @@ -71,7 +72,7 @@ describe('pubsub', function() { subscription.create.bind(subscription), function(callback) { async.times(6, function(_, callback) { - topic.publish(message, options, callback); + publisher.publish(new Buffer(message), options, callback); }, callback); } ], function(err) { @@ -80,16 +81,10 @@ describe('pubsub', function() { return; } - subscription.pull({ - returnImmediately: true, - maxResults: 1 - }, function(err, messages) { - if (err) { - callback(err); - return; - } + subscription.on('error', callback); - callback(null, messages.pop()); + subscription.once('message', function(message) { + callback(null, message); }); }); } @@ -98,7 +93,14 @@ describe('pubsub', function() { // create all needed topics async.each(TOPICS, function(topic, cb) { topic.create(cb); - }, done); + }, function(err) { + if (err) { + done(err); + return; + } + + setTimeout(done, 5000); + }); }); after(function(done) { @@ -143,12 +145,11 @@ describe('pubsub', function() { it('should allow manual paging', function(done) { pubsub.getTopics({ - pageSize: TOPIC_NAMES.length - 1 - }, function(err, topics, nextQuery) { + pageSize: TOPIC_NAMES.length - 1, + gaxOpts: { autoPaginate: false } + }, function(err, topics) { assert.ifError(err); assert(topics.length, TOPIC_NAMES.length - 1); - assert(nextQuery.pageSize, TOPIC_NAMES.length - 1); - assert(!!nextQuery.pageToken, true); done(); }); }); @@ -161,28 +162,55 @@ describe('pubsub', function() { }); }); + it('should honor the autoCreate option', function(done) { + var topic = pubsub.topic(generateTopicName()); + + topic.get({ autoCreate: true }, done); + }); + + it('should confirm if a topic exists', function(done) { + var topic = pubsub.topic(TOPIC_NAMES[0]); + + topic.exists(function(err, exists) { + assert.ifError(err); + assert.strictEqual(exists, true); + done(); + }); + }); + + it('should confirm if a topic does not exist', function(done) { + var topic = pubsub.topic('should-not-exist'); + + topic.exists(function(err, exists) { + assert.ifError(err); + assert.strictEqual(exists, false); + done(); + }); + }); + it('should publish a message', function(done) { var topic = pubsub.topic(TOPIC_NAMES[0]); - topic.publish('message from me', function(err, messageIds) { + var publisher = topic.publisher(); + var message = new Buffer('message from me'); + + publisher.publish(message, function(err, messageId) { assert.ifError(err); - assert.strictEqual(messageIds.length, 1); + assert.strictEqual(typeof messageId, 'string'); done(); }); }); it('should publish a message with attributes', function(done) { - var rawMessage = { - data: 'raw message data', - attributes: { - customAttribute: 'value' - } + var data = new Buffer('raw message data'); + var attrs = { + customAttribute: 'value' }; - publishPop(rawMessage, { raw: true }, function(err, message) { + publishPop(data, attrs, function(err, message) { assert.ifError(err); - assert.strictEqual(message.data, rawMessage.data); - assert.deepEqual(message.attributes, rawMessage.attributes); + assert.deepEqual(message.data, data); + assert.deepEqual(message.attributes, attrs); done(); }); @@ -201,6 +229,7 @@ describe('pubsub', function() { describe('Subscription', function() { var TOPIC_NAME = generateTopicName(); var topic = pubsub.topic(TOPIC_NAME); + var publisher = topic.publisher(); var SUB_NAMES = [ generateSubName(), @@ -208,8 +237,8 @@ describe('pubsub', function() { ]; var SUBSCRIPTIONS = [ - topic.subscription(SUB_NAMES[0], { ackDeadlineSeconds: 30 }), - topic.subscription(SUB_NAMES[1], { ackDeadlineSeconds: 60 }) + topic.subscription(SUB_NAMES[0], { ackDeadline: 30000 }), + topic.subscription(SUB_NAMES[1], { ackDeadline: 60000 }) ]; before(function(done) { @@ -227,7 +256,7 @@ describe('pubsub', function() { } async.times(10, function(_, next) { - topic.publish('hello', next); + publisher.publish(new Buffer('hello'), next); }, function(err) { if (err) { done(err); @@ -299,24 +328,44 @@ describe('pubsub', function() { it('should allow creation and deletion of a subscription', function(done) { var subName = generateSubName(); - topic.subscribe(subName, function(err, sub) { + topic.createSubscription(subName, function(err, sub) { assert.ifError(err); assert(sub instanceof Subscription); sub.delete(done); }); }); - it('should create a subscription with a generated name', function(done) { - topic.subscribe(function(err, sub) { + it('should honor the autoCreate option', function(done) { + var sub = topic.subscription(generateSubName()); + + sub.get({ autoCreate: true }, done); + }); + + it('should confirm if a sub exists', function(done) { + var sub = topic.subscription(SUB_NAMES[0]); + + sub.exists(function(err, exists) { + assert.ifError(err); + assert.strictEqual(exists, true); + done(); + }); + }); + + it('should confirm if a sub does not exist', function(done) { + var sub = topic.subscription('should-not-exist'); + + sub.exists(function(err, exists) { assert.ifError(err); - sub.delete(done); + assert.strictEqual(exists, false); + done(); }); }); it('should create a subscription with message retention', function(done) { + var subName = generateSubName(); var threeDaysInSeconds = 3 * 24 * 60 * 60; - topic.subscribe({ + topic.createSubscription(subName, { messageRetentionDuration: threeDaysInSeconds }, function(err, sub) { assert.ifError(err); @@ -340,116 +389,67 @@ describe('pubsub', function() { }); it('should re-use an existing subscription', function(done) { - pubsub.subscribe(topic, SUB_NAMES[0], done); + pubsub.createSubscription(topic, SUB_NAMES[0], done); }); it('should error when using a non-existent subscription', function(done) { - var subscription = topic.subscription(generateSubName()); - - subscription.pull(function(err) { - assert.equal(err.code, 404); - done(); + var subscription = topic.subscription(generateSubName(), { + maxConnections: 1 }); - }); - - it('should be able to pull and ack', function(done) { - var subscription = topic.subscription(SUB_NAMES[0]); - subscription.pull({ - returnImmediately: true, - maxResults: 1 - }, function(err, msgs) { - assert.ifError(err); - - assert.strictEqual(msgs.length, 1); - - subscription.ack(msgs[0].ackId, done); + subscription.once('error', function(err) { + assert.strictEqual(err.code, 5); + subscription.close(done); }); - }); - - it('should be able to set a new ack deadline', function(done) { - var subscription = topic.subscription(SUB_NAMES[0]); - - subscription.pull({ - returnImmediately: true, - maxResults: 1 - }, function(err, msgs) { - assert.ifError(err); - assert.strictEqual(msgs.length, 1); - - var options = { - ackIds: [msgs[0].ackId], - seconds: 10 - }; - - subscription.setAckDeadline(options, done); + subscription.on('message', function() { + done(new Error('Should not have been called.')); }); }); - it('should receive the published message', function(done) { + it('should receive the published messages', function(done) { + var messageCount = 0; var subscription = topic.subscription(SUB_NAMES[1]); - subscription.pull({ - returnImmediately: true, - maxResults: 1 - }, function(err, msgs) { - assert.ifError(err); - assert.strictEqual(msgs.length, 1); - assert.equal(msgs[0].data, 'hello'); - subscription.ack(msgs[0].ackId, done); - }); - }); + subscription.on('error', done); - it('should receive the chosen amount of results', function(done) { - var subscription = topic.subscription(SUB_NAMES[1]); - var opts = { returnImmediately: true, maxResults: 3 }; + subscription.on('message', function(message) { + assert.deepEqual(message.data, new Buffer('hello')); - subscription.pull(opts, function(err, messages) { - assert.ifError(err); - - assert.equal(messages.length, opts.maxResults); - - var ackIds = messages.map(function(message) { - return message.ackId; - }); - - subscription.ack(ackIds, done); + if (++messageCount === 10) { + subscription.close(done); + } }); }); - it('should allow a custom timeout', function(done) { - var timeout = 5000; + it('should ack the message', function(done) { + var subscription = topic.subscription(SUB_NAMES[1]); - // We need to use a topic without any pending messages to allow the - // connection to stay open. - var topic = pubsub.topic(generateTopicName()); - var subscription = topic.subscription(generateSubName(), { - timeout: timeout - }); + subscription.on('error', done); + subscription.on('message', ack); - async.series([ - topic.create.bind(topic), - subscription.create.bind(subscription), - ], function(err) { - assert.ifError(err); + function ack(message) { + // remove listener to we only ack first message + subscription.removeListener('message', ack); - var times = [Date.now()]; + message.ack(); + setTimeout(() => subscription.close(done), 2500); + } + }); - subscription.pull({ - returnImmediately: false - }, function(err) { - assert.ifError(err); + it('should nack the message', function(done) { + var subscription = topic.subscription(SUB_NAMES[1]); - times.push(Date.now()); - var runTime = times.pop() - times.pop(); + subscription.on('error', done); + subscription.on('message', nack); - assert(runTime >= timeout - 1000); - assert(runTime <= timeout + 1000); + function nack(message) { + // remove listener to we only ack first message + subscription.removeListener('message', nack); - done(); - }); - }); + message.nack(); + setTimeout(() => subscription.close(done), 2500); + } }); }); @@ -506,26 +506,41 @@ describe('pubsub', function() { var SNAPSHOT_NAME = generateSnapshotName(); var topic; + var publisher; var subscription; var snapshot; - before(function(done) { - topic = pubsub.topic(TOPIC_NAMES[0]); - subscription = topic.subscription(generateSubName()); - snapshot = subscription.snapshot(SNAPSHOT_NAME); - subscription.create(done); - }); - - after(function() { + function deleteAllSnapshots() { return pubsub.getSnapshots().then(function(data) { return Promise.all(data[0].map(function(snapshot) { return snapshot.delete(); })); }); + } + + function wait(milliseconds) { + return function() { + return new Promise(function(resolve) { + setTimeout(resolve, milliseconds); + }); + }; + } + + before(function() { + topic = pubsub.topic(TOPIC_NAMES[0]); + publisher = topic.publisher(); + subscription = topic.subscription(generateSubName()); + snapshot = subscription.snapshot(SNAPSHOT_NAME); + + return deleteAllSnapshots() + .then(wait(2500)) + .then(subscription.create.bind(subscription)) + .then(snapshot.create.bind(snapshot)) + .then(wait(2500)); }); - it('should create a snapshot', function(done) { - snapshot.create(done); + after(function() { + return deleteAllSnapshots(); }); it('should get a list of snapshots', function(done) { @@ -557,42 +572,64 @@ describe('pubsub', function() { var messageId; beforeEach(function() { - subscription = topic.subscription(); + subscription = topic.subscription(generateSubName()); return subscription.create().then(function() { - return topic.publish('Hello, world!'); - }).then(function(data) { - messageId = data[0][0]; + return publisher.publish(new Buffer('Hello, world!')); + }).then(function(messageIds) { + messageId = messageIds[0]; }); }); - function checkMessage() { - return subscription.pull().then(function(data) { - var message = data[0][0]; - assert.strictEqual(message.id, messageId); - return message.ack(); - }); - } - - it('should seek to a snapshot', function() { + it('should seek to a snapshot', function(done) { var snapshotName = generateSnapshotName(); - return subscription.createSnapshot(snapshotName).then(function() { - return checkMessage(); - }).then(function() { - return subscription.seek(snapshotName); - }).then(function() { - return checkMessage(); + subscription.createSnapshot(snapshotName, function(err, snapshot) { + assert.ifError(err); + + var messageCount = 0; + + subscription.on('error', done); + subscription.on('message', function(message) { + if (message.id !== messageId) { + return; + } + + message.ack(); + + if (++messageCount === 1) { + snapshot.seek(function(err) { + assert.ifError(err); + }); + return; + } + + assert.strictEqual(messageCount, 2); + subscription.close(done); + }); }); }); - it('should seek to a date', function() { - var date = new Date(); + it('should seek to a date', function(done) { + var messageCount = 0; + + subscription.on('error', done); + subscription.on('message', function(message) { + if (message.id !== messageId) { + return; + } + + message.ack(); + + if (++messageCount === 1) { + subscription.seek(message.publishTime, function(err) { + assert.ifError(err); + }); + return; + } - return checkMessage().then(function() { - return subscription.seek(date); - }).then(function() { - return checkMessage(); + assert.strictEqual(messageCount, 2); + subscription.close(done); }); }); }); diff --git a/packages/pubsub/test/connection-pool.js b/packages/pubsub/test/connection-pool.js new file mode 100644 index 00000000000..e610035c22b --- /dev/null +++ b/packages/pubsub/test/connection-pool.js @@ -0,0 +1,1013 @@ +/** + * Copyright 2017 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. + */ + +'use strict'; + +var assert = require('assert'); +var common = require('@google-cloud/common'); +var events = require('events'); +var extend = require('extend'); +var grpc = require('grpc'); +var proxyquire = require('proxyquire'); +var uuid = require('uuid'); +var util = require('util'); + +var PKG = require('../package.json'); +var v1 = require('../src/v1'); + +var fakeUtil = extend({}, common.util); +var fakeUuid = extend({}, uuid); +var fakeGrpc = extend({}, grpc); + +var v1Override; +function fakeV1(options) { + return (v1Override || v1)(options); +} + +function FakeConnection() { + this.isConnected = false; + this.isPaused = false; + this.ended = false; + + events.EventEmitter.call(this); +} + +util.inherits(FakeConnection, events.EventEmitter); + +FakeConnection.prototype.write = function() {}; + +FakeConnection.prototype.end = function() { + this.ended = true; +}; + +FakeConnection.prototype.pause = function() { + this.isPaused = true; +}; + +FakeConnection.prototype.resume = function() { + this.isPaused = false; +}; + +describe('ConnectionPool', function() { + var ConnectionPool; + var pool; + + var FAKE_PUBSUB_OPTIONS = {}; + + var PUBSUB = { + auth: { + getAuthClient: fakeUtil.noop + }, + options: FAKE_PUBSUB_OPTIONS + }; + + var SUB_NAME = 'test-subscription'; + var SUBSCRIPTION = { + name: SUB_NAME, + pubsub: PUBSUB, + request: fakeUtil.noop + }; + + before(function() { + ConnectionPool = proxyquire('../src/connection-pool.js', { + '@google-cloud/common': { + util: fakeUtil + }, + grpc: fakeGrpc, + uuid: fakeUuid, + './v1': fakeV1 + }); + }); + + beforeEach(function() { + SUBSCRIPTION.request = fakeUtil.noop; + PUBSUB.auth.getAuthClient = fakeUtil.noop; + + pool = new ConnectionPool(SUBSCRIPTION); + pool.queue.forEach(clearTimeout); + pool.queue.length = 0; + }); + + afterEach(function() { + if (pool.isOpen) { + pool.close(); + } + }); + + describe('initialization', function() { + it('should initialize internally used properties', function() { + var open = ConnectionPool.prototype.open; + ConnectionPool.prototype.open = fakeUtil.noop; + + var pool = new ConnectionPool(SUBSCRIPTION); + + assert.strictEqual(pool.subscription, SUBSCRIPTION); + assert(pool.connections instanceof Map); + assert.strictEqual(pool.isPaused, false); + assert.strictEqual(pool.isOpen, false); + assert.strictEqual(pool.failedConnectionAttempts, 0); + assert.strictEqual(pool.noConnectionsTime, 0); + assert.strictEqual(pool.settings.maxConnections, 5); + assert.strictEqual(pool.settings.ackDeadline, 10000); + assert.deepEqual(pool.queue, []); + + ConnectionPool.prototype.open = open; + }); + + it('should create grpc metadata', function() { + assert(pool.metadata_ instanceof grpc.Metadata); + + assert.deepEqual(pool.metadata_.get('x-goog-api-client'), [ + [ + 'gl-node/' + process.versions.node, + 'gccl/' + PKG.version, + 'grpc/' + require('grpc/package.json').version + ].join(' ') + ]); + }); + + it('should respect user specified settings', function() { + var options = { + maxConnections: 10, + ackDeadline: 100 + }; + + var subscription = extend({}, SUBSCRIPTION, options); + var subscriptionCopy = extend({}, subscription); + var pool = new ConnectionPool(subscription); + + assert.deepEqual(pool.settings, options); + assert.deepEqual(subscription, subscriptionCopy); + }); + + it('should inherit from EventEmitter', function() { + assert(pool instanceof events.EventEmitter); + }); + + it('should call open', function(done) { + var open = ConnectionPool.prototype.open; + + ConnectionPool.prototype.open = function() { + ConnectionPool.prototype.open = open; + done(); + }; + + new ConnectionPool(SUBSCRIPTION); + }); + }); + + describe('acquire', function() { + it('should return an error if the pool is closed', function(done) { + var expectedErr = 'No connections available to make request.'; + + pool.isOpen = false; + + pool.acquire(function(err) { + assert(err instanceof Error); + assert.strictEqual(err.message, expectedErr); + done(); + }); + }); + + it('should return a specified connection', function(done) { + var id = 'a'; + var fakeConnection = new FakeConnection(); + + pool.connections.set(id, fakeConnection); + pool.connections.set('b', new FakeConnection()); + + pool.acquire(id, function(err, connection) { + assert.ifError(err); + assert.strictEqual(connection, fakeConnection); + done(); + }); + }); + + it('should return any conn when the specified is missing', function(done) { + var fakeConnection = new FakeConnection(); + + pool.connections.set('a', fakeConnection); + + pool.acquire('b', function(err, connection) { + assert.ifError(err); + assert.strictEqual(connection, fakeConnection); + done(); + }); + }); + + it('should return any connection when id is missing', function(done) { + var fakeConnection = new FakeConnection(); + + pool.connections.set('a', fakeConnection); + + pool.acquire(function(err, connection) { + assert.ifError(err); + assert.strictEqual(connection, fakeConnection); + done(); + }); + }); + + it('should listen for connected event if no conn is ready', function(done) { + var fakeConnection = new FakeConnection(); + + pool.acquire(function(err, connection) { + assert.ifError(err); + assert.strictEqual(connection, fakeConnection); + done(); + }); + + pool.emit('connected', fakeConnection); + }); + }); + + describe('close',function() { + it('should set isOpen to false', function() { + pool.close(); + assert.strictEqual(pool.isOpen, false); + }); + + it('should call end on all active connections', function() { + var a = new FakeConnection(); + var b = new FakeConnection(); + + pool.connections.set('a', a); + pool.connections.set('b', b); + + pool.close(); + + assert.strictEqual(a.ended, true); + assert.strictEqual(b.ended, true); + }); + + it('should clear the connections map', function(done) { + pool.connections.clear = done; + pool.close(); + }); + + it('should clear any timeouts in the queue', function() { + var _clearTimeout = global.clearTimeout; + var clearCalls = 0; + + var fakeHandles = ['a', 'b', 'c', 'd']; + + global.clearTimeout = function(handle) { + assert.strictEqual(handle, fakeHandles[clearCalls++]); + }; + + pool.queue = Array.from(fakeHandles); + pool.close(); + + assert.strictEqual(clearCalls, fakeHandles.length); + assert.strictEqual(pool.queue.length, 0); + + global.clearTimeout = _clearTimeout; + }); + + it('should reset internally used props', function() { + pool.failedConnectionAttempts = 100; + pool.noConnectionsTime = Date.now(); + + pool.close(); + + assert.strictEqual(pool.failedConnectionAttempts, 0); + assert.strictEqual(pool.noConnectionsTime, 0); + }); + + it('should exec a callback when finished closing', function(done) { + pool.close(done); + }); + + it('should use noop when callback is omitted', function(done) { + fakeUtil.noop = function() { + fakeUtil.noop = function() {}; + done(); + }; + + pool.close(); + }); + }); + + describe('createConnection', function() { + var fakeClient; + var fakeConnection; + + beforeEach(function() { + fakeConnection = new FakeConnection(); + + fakeClient = { + streamingPull: function() { + return fakeConnection; + } + }; + + pool.getClient = function(callback) { + callback(null, fakeClient); + }; + }); + + it('should make the correct request', function(done) { + fakeClient.streamingPull = function(metadata) { + assert.strictEqual(metadata, pool.metadata_); + setImmediate(done); + return fakeConnection; + }; + + pool.createConnection(); + }); + + it('should emit any errors that occur when getting client', function(done) { + var error = new Error('err'); + + pool.getClient = function(callback) { + callback(error); + }; + + pool.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); + + pool.createConnection(); + }); + + describe('connection', function() { + var fakeId; + + beforeEach(function() { + fakeId = uuid.v4(); + + fakeUuid.v4 = function() { + return fakeId; + }; + }); + + it('should create a connection', function(done) { + fakeConnection.write = function(reqOpts) { + assert.deepEqual(reqOpts, { + subscription: SUB_NAME, + streamAckDeadlineSeconds: pool.settings.ackDeadline / 1000 + }); + }; + + pool.connections.set = function(id, connection) { + assert.strictEqual(id, fakeId); + assert.strictEqual(connection, fakeConnection); + done(); + }; + + pool.createConnection(); + }); + + it('should pause the connection if the pool is paused', function(done) { + fakeConnection.pause = done; + pool.isPaused = true; + pool.createConnection(); + }); + + describe('error events', function() { + it('should emit errors to the pool', function(done) { + var error = new Error('err'); + + pool.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); + + pool.createConnection(); + fakeConnection.emit('error', error); + }); + }); + + describe('metadata events', function() { + it('should do nothing if the metadata is empty', function(done) { + var metadata = new grpc.Metadata(); + + pool.on('connected', done); // should not fire + pool.createConnection(); + + fakeConnection.emit('metadata', metadata); + done(); + }); + + it('should reset counters and fire connected', function(done) { + var metadata = new grpc.Metadata(); + + metadata.set('date', 'abc'); + + pool.on('connected', function(connection) { + assert.strictEqual(connection, fakeConnection); + assert(fakeConnection.isConnected); + assert.strictEqual(pool.noConnectionsTime, 0); + assert.strictEqual(pool.failedConnectionAttempts, 0); + done(); + }); + + pool.createConnection(); + fakeConnection.emit('metadata', metadata); + }); + }); + + describe('status events', function() { + beforeEach(function() { + pool.connections.set('a', new FakeConnection()); + }); + + it('should cancel any error events', function(done) { + var fakeError = { code: 4 }; + + pool.on('error', done); // should not fire + pool.createConnection(); + + fakeConnection.emit('error', fakeError); + fakeConnection.emit('status', fakeError); + + done(); + }); + + it('should close and delete the connection', function(done) { + var endCalled = false; + + pool.createConnection(); + + fakeConnection.end = function() { + endCalled = true; + }; + + pool.connections.delete = function(id) { + assert.strictEqual(id, fakeId); + done(); + }; + + fakeConnection.emit('status', {}); + }); + + it('should increment the failed connection counter', function() { + pool.failedConnectionAttempts = 0; + fakeConnection.isConnected = false; + + pool.createConnection(); + fakeConnection.emit('status', {}); + + assert.strictEqual(pool.failedConnectionAttempts, 1); + }); + + it('should not incr. the failed connection counter', function() { + pool.failedConnectionAttempts = 0; + fakeConnection.isConnected = true; + + pool.createConnection(); + fakeConnection.emit('status', {}); + + assert.strictEqual(pool.failedConnectionAttempts, 0); + }); + + it('should capture the date when no connections are found', function() { + var dateNow = global.Date.now; + + var fakeDate = Date.now(); + global.Date.now = function() { + return fakeDate; + }; + + pool.noConnectionsTime = 0; + pool.isConnected = function() { + return false; + }; + + pool.createConnection(); + fakeConnection.emit('status', {}); + + assert.strictEqual(pool.noConnectionsTime, fakeDate); + global.Date.now = dateNow; + }); + + it('should not capture the date when already set', function() { + pool.noConnectionsTime = 123; + pool.isConnected = function() { + return false; + }; + + pool.createConnection(); + fakeConnection.emit('status', {}); + + assert.strictEqual(pool.noConnectionsTime, 123); + }); + + it('should not capture the date if a conn. is found', function() { + pool.noConnectionsTime = 0; + pool.isConnected = function() { + return true; + }; + + pool.createConnection(); + fakeConnection.emit('status', {}); + + assert.strictEqual(pool.noConnectionsTime, 0); + }); + + it('should queue a connection if status is retryable', function(done) { + var fakeStatus = {}; + + pool.shouldReconnect = function(status) { + assert.strictEqual(status, fakeStatus); + return true; + }; + + pool.queueConnection = done; + + pool.createConnection(); + fakeConnection.emit('status', fakeStatus); + }); + + it('should emit error if no pending conn. are found', function(done) { + var error = { + code: 4, + details: 'Deadline Exceeded' + }; + + pool.shouldReconnect = function() { + return false; + }; + + // will only emit status errors if pool is empty + pool.connections = new Map(); + + pool.on('error', function(err) { + assert.strictEqual(err.code, error.code); + assert.strictEqual(err.message, error.details); + done(); + }); + + pool.createConnection(); + fakeConnection.emit('status', error); + }); + }); + + describe('data events', function() { + it('should emit messages', function(done) { + var fakeResp = {}; + var fakeMessage = {}; + + pool.createMessage = function(id, resp) { + assert.strictEqual(id, fakeId); + assert.strictEqual(resp, fakeResp); + return fakeMessage; + }; + + pool.on('message', function(message) { + assert.strictEqual(message, fakeMessage); + done(); + }); + + pool.createConnection(); + fakeConnection.emit('data', { receivedMessages: [fakeResp] }); + }); + }); + }); + }); + + describe('createMessage', function() { + var message; + var globalDateNow; + + var CONNECTION_ID = 'abc'; + var FAKE_DATE_NOW = Date.now(); + + var PT = { + seconds: 6838383, + nanos: 20323838 + }; + + var RESP = { + ackId: 'def', + message: { + messageId: 'ghi', + data: new Buffer('hello'), + attributes: { + a: 'a' + }, + publishTime: PT + } + }; + + before(function() { + globalDateNow = global.Date.now; + global.Date.now = function() { + return FAKE_DATE_NOW; + }; + }); + + beforeEach(function() { + message = pool.createMessage(CONNECTION_ID, RESP); + }); + + after(function() { + global.Date.now = globalDateNow; + }); + + it('should capture the connection id', function() { + assert.strictEqual(message.connectionId, CONNECTION_ID); + }); + + it('should capture the message data', function() { + var expectedPublishTime = new Date( + parseInt(PT.seconds, 10) * 1000 + parseInt(PT.nanos, 10) / 1e6); + + assert.strictEqual(message.ackId, RESP.ackId); + assert.strictEqual(message.id, RESP.message.messageId); + assert.strictEqual(message.data, RESP.message.data); + assert.strictEqual(message.attributes, RESP.message.attributes); + assert.deepEqual(message.publishTime, expectedPublishTime); + assert.strictEqual(message.received, FAKE_DATE_NOW); + }); + + it('should create a read-only message length property', function() { + assert.strictEqual(message.length, RESP.message.data.length); + + assert.throws(function() { + message.length = 3; + }); + }); + + it('should create an ack method', function(done) { + SUBSCRIPTION.ack_ = function(message_) { + assert.strictEqual(message_, message); + done(); + }; + + message.ack(); + }); + + it('should create a nack method', function(done) { + SUBSCRIPTION.nack_ = function(message_) { + assert.strictEqual(message_, message); + done(); + }; + + message.nack(); + }); + }); + + describe('getClient', function() { + var fakeAuthClient = {}; + + function FakeSubscriber(address, creds, options) { + this.address = address; + this.creds = creds; + this.options = options; + } + + beforeEach(function() { + PUBSUB.auth.getAuthClient = function(callback) { + callback(null, fakeAuthClient); + }; + + v1Override = function() { + return { + Subscriber: FakeSubscriber + }; + }; + }); + + it('should return the cached client when available', function(done) { + var fakeClient = pool.client = {}; + + pool.getClient(function(err, client) { + assert.ifError(err); + assert.strictEqual(client, fakeClient); + done(); + }); + }); + + it('should return any auth errors', function(done) { + var error = new Error('err'); + + PUBSUB.auth.getAuthClient = function(callback) { + callback(error); + }; + + pool.getClient(function(err, client) { + assert.strictEqual(err, error); + assert.strictEqual(client, undefined); + done(); + }); + }); + + it('should create/use grpc credentials', function(done) { + var fakeSslCreds = {}; + var fakeGoogCreds = {}; + var fakeCombinedCreds = {}; + + fakeGrpc.credentials = { + createSsl: function() { + return fakeSslCreds; + }, + createFromGoogleCredential: function(authClient) { + assert.strictEqual(authClient, fakeAuthClient); + return fakeGoogCreds; + }, + combineChannelCredentials: function(sslCreds, googCreds) { + assert.strictEqual(sslCreds, fakeSslCreds); + assert.strictEqual(googCreds, fakeGoogCreds); + return fakeCombinedCreds; + } + }; + + pool.getClient(function(err, client) { + assert.ifError(err); + assert(client instanceof FakeSubscriber); + assert.strictEqual(client.creds, fakeCombinedCreds); + done(); + }); + }); + + it('should pass the pubsub options into the gax fn', function(done) { + v1Override = function(options) { + assert.strictEqual(options, FAKE_PUBSUB_OPTIONS); + setImmediate(done); + + return { + Subscriber: FakeSubscriber + }; + }; + + pool.getClient(assert.ifError); + }); + + it('should pass in the correct the args to the Subscriber', function(done) { + var fakeCreds = {}; + fakeGrpc.credentials.combineChannelCredentials = function() { + return fakeCreds; + }; + + var fakeAddress = 'a.b.c'; + fakeV1.SERVICE_ADDRESS = fakeAddress; + + var fakeUserAgent = 'a-b-c'; + fakeUtil.getUserAgentFromPackageJson = function(packageJson) { + assert.deepEqual(packageJson, PKG); + return fakeUserAgent; + }; + + pool.getClient(function(err, client) { + assert.ifError(err); + assert(client instanceof FakeSubscriber); + assert.strictEqual(client.address, fakeAddress); + assert.strictEqual(client.creds, fakeCreds); + + assert.deepEqual(client.options, { + 'grpc.max_receive_message_length': 20000001, + 'grpc.primary_user_agent': fakeUserAgent + }); + + done(); + }); + }); + }); + + describe('isConnected', function() { + it('should return true when at least one stream is connected', function() { + var connections = pool.connections = new Map(); + + connections.set('a', new FakeConnection()); + connections.set('b', new FakeConnection()); + connections.set('c', new FakeConnection()); + connections.set('d', new FakeConnection()); + + var conn = new FakeConnection(); + conn.isConnected = true; + connections.set('e', conn); + + assert(pool.isConnected()); + }); + + it('should return false when there is no connection', function() { + var connections = pool.connections = new Map(); + + connections.set('a', new FakeConnection()); + connections.set('b', new FakeConnection()); + connections.set('c', new FakeConnection()); + connections.set('d', new FakeConnection()); + connections.set('e', new FakeConnection()); + + assert(!pool.isConnected()); + }); + + it('should return false when the map is empty', function() { + pool.connections = new Map(); + assert(!pool.isConnected()); + }); + }); + + describe('open', function() { + beforeEach(function() { + pool.queueConnection = fakeUtil.noop; + }); + + it('should make the specified number of connections', function() { + var expectedCount = 5; + var connectionCount = 0; + + pool.queueConnection = function() { + connectionCount += 1; + }; + + pool.settings.maxConnections = expectedCount; + pool.open(); + + assert.strictEqual(expectedCount, connectionCount); + }); + + it('should set the isOpen flag to true', function() { + pool.open(); + assert(pool.isOpen); + }); + + it('should reset internal used props', function() { + pool.failedConnectionAttempts = 100; + pool.noConnectionsTime = 0; + + pool.open(); + + assert.strictEqual(pool.failedConnectionAttempts, 0); + assert.strictEqual(pool.noConnectionsTime, Date.now()); + }); + }); + + describe('pause', function() { + it('should set the isPaused flag to true', function() { + pool.pause(); + assert(pool.isPaused); + }); + + it('should pause all the connections', function() { + var a = new FakeConnection(); + var b = new FakeConnection(); + + pool.connections.set('a', a); + pool.connections.set('b', b); + + pool.pause(); + + assert(a.isPaused); + assert(b.isPaused); + }); + }); + + describe('queueConnection', function() { + var fakeTimeoutHandle = 123; + + var _setTimeout; + var _random; + var _open; + + before(function() { + _setTimeout = global.setTimeout; + _random = global.Math.random; + + _open = ConnectionPool.prototype.open; + // prevent open from calling queueConnection + ConnectionPool.prototype.open = fakeUtil.noop; + }); + + beforeEach(function() { + Math.random = function() { + return 1; + }; + + global.setTimeout = function(cb) { + cb(); + return fakeTimeoutHandle; + }; + + pool.failedConnectionAttempts = 0; + pool.createConnection = fakeUtil.noop; + }); + + after(function() { + global.setTimeout = _setTimeout; + global.Math.random = _random; + ConnectionPool.prototype.open = _open; + }); + + it('should set a timeout to create the connection', function(done) { + pool.createConnection = done; + + global.setTimeout = function(cb, delay) { + assert.strictEqual(delay, 0); + cb(); // should call the done fn + }; + + pool.queueConnection(); + }); + + it('should factor in the number of failed requests', function(done) { + pool.createConnection = done; + pool.failedConnectionAttempts = 3; + + global.setTimeout = function(cb, delay) { + assert.strictEqual(delay, 9000); + cb(); // should call the done fn + }; + + pool.queueConnection(); + }); + + it('should capture the timeout handle', function() { + pool.queueConnection(); + assert.deepEqual(pool.queue, [fakeTimeoutHandle]); + }); + + it('should remove the timeout handle once it fires', function(done) { + pool.createConnection = function() { + assert.strictEqual(pool.queue.length, 0); + done(); + }; + + pool.queueConnection(); + }); + }); + + describe('resume', function() { + it('should set the isPaused flag to false', function() { + pool.resume(); + assert.strictEqual(pool.isPaused, false); + }); + + it('should resume all the connections', function() { + var a = new FakeConnection(); + var b = new FakeConnection(); + + pool.connections.set('a', a); + pool.connections.set('b', b); + + pool.resume(); + + assert.strictEqual(a.isPaused, false); + assert.strictEqual(b.isPaused, false); + }); + }); + + describe('shouldReconnect', function() { + it('should not reconnect if the pool is closed', function() { + pool.isOpen = false; + assert.strictEqual(pool.shouldReconnect({}), false); + }); + + it('should return true for retryable errors', function() { + assert(pool.shouldReconnect({ code: 0 })); // OK + assert(pool.shouldReconnect({ code: 1 })); // Canceled + assert(pool.shouldReconnect({ code: 4 })); // DeadlineExceeded + assert(pool.shouldReconnect({ code: 8 })); // ResourceExhausted + assert(pool.shouldReconnect({ code: 13 })); // Internal + assert(pool.shouldReconnect({ code: 14 })); // Unavailable + }); + + it('should return false for non-retryable errors', function() { + assert(!pool.shouldReconnect({ code: 2 })); // Unknown + assert(!pool.shouldReconnect({ code: 3 })); // InvalidArgument + assert(!pool.shouldReconnect({ code: 5 })); // NotFound + assert(!pool.shouldReconnect({ code: 6 })); // AlreadyExists + assert(!pool.shouldReconnect({ code: 7 })); // PermissionDenied + assert(!pool.shouldReconnect({ code: 9 })); // FailedPrecondition + assert(!pool.shouldReconnect({ code: 10 })); // Aborted + assert(!pool.shouldReconnect({ code: 11 })); // OutOfRange + assert(!pool.shouldReconnect({ code: 12 })); // Unimplemented + assert(!pool.shouldReconnect({ code: 15 })); // DataLoss + assert(!pool.shouldReconnect({ code: 16 })); // Unauthenticated + }); + + it('should not retry if no connection can be made', function() { + var fakeStatus = { + code: 4 + }; + + pool.noConnectionsTime = Date.now() - 300001; + + assert.strictEqual(pool.shouldReconnect(fakeStatus), false); + }); + + it('should return true if all conditions are met', function() { + var fakeStatus = { + code: 4 + }; + + pool.noConnectionsTime = 0; + + assert.strictEqual(pool.shouldReconnect(fakeStatus), true); + }); + }); +}); diff --git a/packages/pubsub/test/histogram.js b/packages/pubsub/test/histogram.js new file mode 100644 index 00000000000..eee2693c194 --- /dev/null +++ b/packages/pubsub/test/histogram.js @@ -0,0 +1,120 @@ +/** + * Copyright 2017 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. + */ + +'use strict'; + +var assert = require('assert'); + +var Histogram = require('../src/histogram.js'); + +describe('Histogram', function() { + var histogram; + + var MIN_VALUE = 10000; + var MAX_VALUE = 600000; + + beforeEach(function() { + histogram = new Histogram(); + }); + + describe('initialization', function() { + it('should create a data map', function() { + assert(histogram.data instanceof Map); + }); + + it('should set the initial length to 0', function() { + assert.strictEqual(histogram.length, 0); + }); + }); + + describe('add', function() { + it('should increment a value', function() { + histogram.data.set(MIN_VALUE, 1); + histogram.add(MIN_VALUE); + + assert.strictEqual(histogram.data.get(MIN_VALUE), 2); + }); + + it('should initialize a value if absent', function() { + histogram.add(MIN_VALUE); + + assert.strictEqual(histogram.data.get(MIN_VALUE), 1); + }); + + it('should adjust the length for each item added', function() { + histogram.add(MIN_VALUE); + histogram.add(MIN_VALUE); + histogram.add(MIN_VALUE * 2); + + assert.strictEqual(histogram.length, 3); + }); + + it('should cap the value', function() { + var outOfBounds = MAX_VALUE + MIN_VALUE; + + histogram.add(outOfBounds); + + assert.strictEqual(histogram.data.get(outOfBounds), undefined); + assert.strictEqual(histogram.data.get(MAX_VALUE), 1); + }); + + it('should apply a minimum', function() { + var outOfBounds = MIN_VALUE - 1000; + + histogram.add(outOfBounds); + + assert.strictEqual(histogram.data.get(outOfBounds), undefined); + assert.strictEqual(histogram.data.get(MIN_VALUE), 1); + }); + + it('should use seconds level precision', function() { + var ms = 303823; + var expected = 304000; + + histogram.add(ms); + + assert.strictEqual(histogram.data.get(ms), undefined); + assert.strictEqual(histogram.data.get(expected), 1); + }); + }); + + describe('percentile', function() { + function range(a, b) { + var result = []; + + for (; a < b; a++) { + result.push(a); + } + + return result; + } + + it('should return the nth percentile', function() { + range(100, 201).forEach(function(value) { + histogram.add(value * 1000); + }); + + assert.strictEqual(histogram.percentile(100), 200000); + assert.strictEqual(histogram.percentile(101), 200000); + assert.strictEqual(histogram.percentile(99), 199000); + assert.strictEqual(histogram.percentile(1), 101000); + }); + + it('should return the min value if unable to determine', function() { + assert.strictEqual(histogram.percentile(99), MIN_VALUE); + }); + }); +}); diff --git a/packages/pubsub/test/iam.js b/packages/pubsub/test/iam.js index 46d77e74158..25dde7ed8fc 100644 --- a/packages/pubsub/test/iam.js +++ b/packages/pubsub/test/iam.js @@ -45,7 +45,6 @@ describe('IAM', function() { var iam; var PUBSUB = { - defaultBaseUrl_: 'base-url', options: {} }; var ID = 'id'; @@ -72,7 +71,7 @@ describe('IAM', function() { var config = iam.calledWith_[0]; var options = iam.calledWith_[1]; - assert.strictEqual(config.baseUrl, PUBSUB.defaultBaseUrl_); + assert.strictEqual(config.baseUrl, 'pubsub.googleapis.com'); var protosDir = path.resolve(__dirname, '../protos'); assert.strictEqual(config.protosDir, protosDir); diff --git a/packages/pubsub/test/index.js b/packages/pubsub/test/index.js index eaa656babda..a7fc25c9d67 100644 --- a/packages/pubsub/test/index.js +++ b/packages/pubsub/test/index.js @@ -19,18 +19,18 @@ var arrify = require('arrify'); var assert = require('assert'); var extend = require('extend'); -var path = require('path'); var proxyquire = require('proxyquire'); var util = require('@google-cloud/common').util; +var PKG = require('../package.json'); +var v1 = require('../src/v1/index.js'); + var SubscriptionCached = require('../src/subscription.js'); var SubscriptionOverride; -var Topic = require('../src/topic.js'); - -function Subscription(a, b) { +function Subscription(a, b, c) { var OverrideFn = SubscriptionOverride || SubscriptionCached; - return new OverrideFn(a, b); + return new OverrideFn(a, b, c); } var promisified = false; @@ -50,16 +50,11 @@ var fakeUtil = extend({}, util, { } }); -function FakeGrpcService() { +function FakeSnapshot() { this.calledWith_ = arguments; } -var grpcServiceRequestOverride; -FakeGrpcService.prototype.request = function() { - return (grpcServiceRequestOverride || util.noop).apply(this, arguments); -}; - -function FakeSnapshot() { +function FakeTopic() { this.calledWith_ = arguments; } @@ -85,6 +80,16 @@ var fakePaginator = { } }; +var googleAutoAuthOverride; +function fakeGoogleAutoAuth() { + return (googleAutoAuthOverride || util.noop).apply(null, arguments); +} + +var v1Override; +function fakeV1() { + return (v1Override || util.noop).apply(null, arguments); +} + var GAX_CONFIG_PUBLISHER_OVERRIDE = {}; var GAX_CONFIG_SUBSCRIBER_OVERRIDE = {}; @@ -116,13 +121,11 @@ describe('PubSub', function() { paginator: fakePaginator, util: fakeUtil }, - '@google-cloud/common-grpc': { - Service: FakeGrpcService - }, + 'google-auto-auth': fakeGoogleAutoAuth, './snapshot.js': FakeSnapshot, './subscription.js': Subscription, - './topic.js': Topic, - + './topic.js': FakeTopic, + './v1': fakeV1, './v1/publisher_client_config.json': GAX_CONFIG.Publisher, './v1/subscriber_client_config.json': GAX_CONFIG.Subscriber }); @@ -135,7 +138,8 @@ describe('PubSub', function() { }); beforeEach(function() { - grpcServiceRequestOverride = null; + v1Override = null; + googleAutoAuthOverride = null; SubscriptionOverride = null; pubsub = new PubSub(OPTIONS); pubsub.projectId = PROJECT_ID; @@ -175,52 +179,328 @@ describe('PubSub', function() { fakeUtil.normalizeArguments = normalizeArguments; }); - it('should inherit from GrpcService', function() { - assert(pubsub instanceof FakeGrpcService); + it('should attempt to determine the service path and port', function() { + var determineBaseUrl_ = PubSub.prototype.determineBaseUrl_; + var called = false; - var calledWith = pubsub.calledWith_[0]; + PubSub.prototype.determineBaseUrl_ = function() { + PubSub.prototype.determineBaseUrl_ = determineBaseUrl_; + called = true; + }; - var baseUrl = 'pubsub.googleapis.com'; - assert.strictEqual(calledWith.baseUrl, baseUrl); + new PubSub({}); + assert(called); + }); - var protosDir = path.resolve(__dirname, '../protos'); - assert.strictEqual(calledWith.protosDir, protosDir); + it('should initialize the API object', function() { + assert.deepEqual(pubsub.api, {}); + }); - assert.deepStrictEqual(calledWith.protoServices, { - Publisher: { - path: 'google/pubsub/v1/pubsub.proto', - service: 'pubsub.v1' - }, - Subscriber: { - path: 'google/pubsub/v1/pubsub.proto', - service: 'pubsub.v1' + it('should cache a local google-auto-auth instance', function() { + var fakeGoogleAutoAuthInstance = {}; + var options = { + a: 'b', + c: 'd' + }; + + googleAutoAuthOverride = function(options_) { + assert.deepEqual(options_, extend({ + scopes: v1.ALL_SCOPES, + 'grpc.max_receive_message_length': 20000001, + libName: 'gccl', + libVersion: PKG.version + }, options)); + return fakeGoogleAutoAuthInstance; + }; + + var pubsub = new PubSub(options); + assert.strictEqual(pubsub.auth, fakeGoogleAutoAuthInstance); + }); + + it('should localize the options provided', function() { + assert.deepEqual(pubsub.options, extend({ + scopes: v1.ALL_SCOPES, + 'grpc.max_receive_message_length': 20000001, + libName: 'gccl', + libVersion: PKG.version + }, OPTIONS)); + }); + + it('should set the projectId', function() { + assert.strictEqual(pubsub.projectId, PROJECT_ID); + }); + + it('should default the projectId to the token', function() { + var pubsub = new PubSub({}); + assert.strictEqual(pubsub.projectId, '{{projectId}}'); + }); + }); + + + describe('createSubscription', function() { + var TOPIC_NAME = 'topic'; + var TOPIC = extend(new FakeTopic(), { + name: 'projects/' + PROJECT_ID + '/topics/' + TOPIC_NAME + }); + + var SUB_NAME = 'subscription'; + var SUBSCRIPTION = { + name: 'projects/' + PROJECT_ID + '/subscriptions/' + SUB_NAME + }; + + var apiResponse = { + name: 'subscription-name' + }; + + it('should throw if no Topic is provided', function() { + assert.throws(function() { + pubsub.createSubscription(); + }, /A Topic is required for a new subscription\./); + }); + + it('should throw if no subscription name is provided', function() { + assert.throws(function() { + pubsub.createSubscription(TOPIC_NAME); + }, /A subscription name is required./); + }); + + it('should not require configuration options', function(done) { + pubsub.request = function(config, callback) { + callback(null, apiResponse); + }; + + pubsub.createSubscription(TOPIC, SUB_NAME, done); + }); + + it('should allow undefined/optional configuration options', function(done) { + pubsub.request = function(config, callback) { + callback(null, apiResponse); + }; + + pubsub.createSubscription(TOPIC, SUB_NAME, undefined, done); + }); + + it('should create a Subscription', function(done) { + var opts = { a: 'b', c: 'd' }; + + pubsub.request = util.noop; + + pubsub.subscription = function(subName, options) { + assert.strictEqual(subName, SUB_NAME); + assert.deepEqual(options, opts); + setImmediate(done); + return SUBSCRIPTION; + }; + + pubsub.createSubscription(TOPIC, SUB_NAME, opts, assert.ifError); + }); + + it('should create a Topic object from a string', function(done) { + pubsub.request = util.noop; + + pubsub.topic = function(topicName) { + assert.strictEqual(topicName, TOPIC_NAME); + setImmediate(done); + return TOPIC; + }; + + pubsub.createSubscription(TOPIC_NAME, SUB_NAME, assert.ifError); + }); + + it('should send correct request', function(done) { + var options = { + gaxOpts: {} + }; + + pubsub.topic = function(topicName) { + return { + name: topicName + }; + }; + + pubsub.subscription = function(subName) { + return { + name: subName + }; + }; + + pubsub.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'createSubscription'); + assert.strictEqual(config.reqOpts.topic, TOPIC.name); + assert.strictEqual(config.reqOpts.name, SUB_NAME); + assert.strictEqual(config.gaxOpts, options.gaxOpts); + done(); + }; + + pubsub.createSubscription(TOPIC, SUB_NAME, options, assert.ifError); + }); + + it('should pass options to the api request', function(done) { + var options = { + retainAckedMessages: true, + pushEndpoint: 'https://domain/push', + }; + + var expectedBody = extend({ + topic: TOPIC.name, + name: SUB_NAME + }, options, { + pushConfig: { + pushEndpoint: options.pushEndpoint } }); - assert.deepEqual(calledWith.scopes, [ - 'https://www.googleapis.com/auth/pubsub', - 'https://www.googleapis.com/auth/cloud-platform' - ]); - assert.deepEqual(calledWith.packageJson, require('../package.json')); + delete expectedBody.pushEndpoint; + + pubsub.topic = function() { + return { + name: TOPIC_NAME + }; + }; + + pubsub.subscription = function() { + return { + name: SUB_NAME + }; + }; + + pubsub.request = function(config) { + assert.notStrictEqual(config.reqOpts, options); + assert.deepEqual(config.reqOpts, expectedBody); + done(); + }; + + pubsub.createSubscription(TOPIC, SUB_NAME, options, assert.ifError); }); - it('should set the defaultBaseUrl_', function() { - assert.strictEqual(pubsub.defaultBaseUrl_, 'pubsub.googleapis.com'); + it('should discard flow control options', function(done) { + var options = { + flowControl: {} + }; + + var expectedBody = { + topic: TOPIC.name, + name: SUB_NAME + }; + + pubsub.topic = function() { + return { + name: TOPIC_NAME + }; + }; + + pubsub.subscription = function() { + return { + name: SUB_NAME + }; + }; + + pubsub.request = function(config) { + assert.notStrictEqual(config.reqOpts, options); + assert.deepEqual(config.reqOpts, expectedBody); + done(); + }; + + pubsub.createSubscription(TOPIC, SUB_NAME, options, assert.ifError); }); - it('should use the PUBSUB_EMULATOR_HOST env var', function() { - var pubSubHost = 'pubsub-host'; - process.env.PUBSUB_EMULATOR_HOST = pubSubHost; + describe('message retention', function() { + it('should accept a number', function(done) { + var threeDaysInSeconds = 3 * 24 * 60 * 60; - var pubsub = new PubSub({ projectId: 'project-id' }); - delete process.env.PUBSUB_EMULATOR_HOST; + pubsub.request = function(config) { + assert.strictEqual(config.reqOpts.retainAckedMessages, true); + + assert.strictEqual( + config.reqOpts.messageRetentionDuration.seconds, + threeDaysInSeconds + ); - var calledWith = pubsub.calledWith_[0]; - assert.strictEqual(calledWith.baseUrl, pubSubHost); + assert.strictEqual(config.reqOpts.messageRetentionDuration.nanos, 0); + + done(); + }; + + pubsub.createSubscription(TOPIC_NAME, SUB_NAME, { + messageRetentionDuration: threeDaysInSeconds + }, assert.ifError); + }); }); - it('should localize the options provided', function() { - assert.strictEqual(pubsub.options, OPTIONS); + describe('error', function() { + var error = new Error('Error.'); + var apiResponse = { name: SUB_NAME }; + + beforeEach(function() { + pubsub.request = function(config, callback) { + callback(error, apiResponse); + }; + }); + + it('should re-use existing subscription', function(done) { + var apiResponse = { code: 6 }; + + pubsub.subscription = function() { + return SUBSCRIPTION; + }; + + pubsub.request = function(config, callback) { + callback({ code: 6 }, apiResponse); + }; + + pubsub.createSubscription(TOPIC_NAME, SUB_NAME, function(err, sub) { + assert.ifError(err); + assert.strictEqual(sub, SUBSCRIPTION); + done(); + }); + }); + + it('should return error & API response to the callback', function(done) { + pubsub.request = function(config, callback) { + callback(error, apiResponse); + }; + + function callback(err, sub, resp) { + assert.strictEqual(err, error); + assert.strictEqual(sub, null); + assert.strictEqual(resp, apiResponse); + done(); + } + + pubsub.createSubscription(TOPIC_NAME, SUB_NAME, callback); + }); + }); + + describe('success', function() { + var apiResponse = { name: SUB_NAME }; + + beforeEach(function() { + pubsub.request = function(config, callback) { + callback(null, apiResponse); + }; + }); + + it('should return Subscription & resp to the callback', function(done) { + var subscription = {}; + + pubsub.subscription = function() { + return subscription; + }; + + pubsub.request = function(config, callback) { + callback(null, apiResponse); + }; + + function callback(err, sub, resp) { + assert.ifError(err); + assert.strictEqual(sub, subscription); + assert.strictEqual(resp, apiResponse); + done(); + } + + pubsub.createSubscription(TOPIC_NAME, SUB_NAME, callback); + }); }); }); @@ -228,23 +508,25 @@ describe('PubSub', function() { it('should make the correct API request', function(done) { var topicName = 'new-topic-name'; var formattedName = 'formatted-name'; + var gaxOpts = {}; - var formatName_ = Topic.formatName_; - Topic.formatName_ = function(projectId, name) { - Topic.formatName_ = formatName_; - assert.strictEqual(projectId, pubsub.projectId); + pubsub.topic = function(name) { assert.strictEqual(name, topicName); - return formattedName; + + return { + name: formattedName + }; }; - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Publisher'); - assert.strictEqual(protoOpts.method, 'createTopic'); - assert.strictEqual(reqOpts.name, formattedName); + pubsub.request = function(config) { + assert.strictEqual(config.client, 'publisherClient'); + assert.strictEqual(config.method, 'createTopic'); + assert.deepEqual(config.reqOpts, { name: formattedName }); + assert.deepEqual(config.gaxOpts, gaxOpts); done(); }; - pubsub.createTopic(topicName, function() {}); + pubsub.createTopic(topicName, gaxOpts, function() {}); }); describe('error', function() { @@ -252,7 +534,7 @@ describe('PubSub', function() { var apiResponse = {}; beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { + pubsub.request = function(config, callback) { callback(error, apiResponse); }; }); @@ -271,7 +553,7 @@ describe('PubSub', function() { var apiResponse = {}; beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { + pubsub.request = function(config, callback) { callback(null, apiResponse); }; }); @@ -302,36 +584,116 @@ describe('PubSub', function() { }); }); - describe('getSnapshots', function() { - var SNAPSHOT_NAME = 'fake-snapshot'; - var apiResponse = { snapshots: [{ name: SNAPSHOT_NAME }]}; + describe('determineBaseUrl_', function() { + function setHost(host) { + process.env.PUBSUB_EMULATOR_HOST = host; + } beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); - }; + delete process.env.PUBSUB_EMULATOR_HOST; }); - it('should accept a query and a callback', function(done) { - pubsub.getSnapshots({}, done); - }); + it('should do nothing if correct options are not set', function() { + pubsub.determineBaseUrl_(); - it('should accept just a callback', function(done) { - pubsub.getSnapshots(done); + assert.strictEqual(pubsub.options.servicePath, undefined); + assert.strictEqual(pubsub.options.port, undefined); + }); + + it('should use the apiEndpoint option', function() { + var defaultBaseUrl_ = 'defaulturl'; + var testingUrl = 'localhost:8085'; + + setHost(defaultBaseUrl_); + pubsub.options.apiEndpoint = testingUrl; + pubsub.determineBaseUrl_(); + + assert.strictEqual(pubsub.options.servicePath, 'localhost'); + assert.strictEqual(pubsub.options.port, '8085'); + }); + + it('should remove slashes from the baseUrl', function() { + setHost('localhost:8080/'); + pubsub.determineBaseUrl_(); + assert.strictEqual(pubsub.options.servicePath, 'localhost'); + assert.strictEqual(pubsub.options.port, '8080'); + + setHost('localhost:8081//'); + pubsub.determineBaseUrl_(); + assert.strictEqual(pubsub.options.servicePath, 'localhost'); + assert.strictEqual(pubsub.options.port, '8081'); + }); + + it('should set the port to undefined if not set', function() { + setHost('localhost'); + pubsub.determineBaseUrl_(); + assert.strictEqual(pubsub.options.servicePath, 'localhost'); + assert.strictEqual(pubsub.options.port, undefined); + }); + + describe('with PUBSUB_EMULATOR_HOST environment variable', function() { + var PUBSUB_EMULATOR_HOST = 'localhost:9090'; + + beforeEach(function() { + setHost(PUBSUB_EMULATOR_HOST); + }); + + after(function() { + delete process.env.PUBSUB_EMULATOR_HOST; + }); + + it('should use the PUBSUB_EMULATOR_HOST env var', function() { + pubsub.determineBaseUrl_(); + assert.strictEqual(pubsub.options.servicePath, 'localhost'); + assert.strictEqual(pubsub.options.port, '9090'); + }); + }); + }); + + describe('getSnapshots', function() { + var SNAPSHOT_NAME = 'fake-snapshot'; + var apiResponse = { snapshots: [{ name: SNAPSHOT_NAME }]}; + + beforeEach(function() { + pubsub.request = function(config, callback) { + callback(null, apiResponse.snapshots, {}, apiResponse); + }; + }); + + it('should accept a query and a callback', function(done) { + pubsub.getSnapshots({}, done); + }); + + it('should accept just a callback', function(done) { + pubsub.getSnapshots(done); }); it('should build the right request', function(done) { - var options = { a: 'b', c: 'd' }; - var originalOptions = extend({}, options); + var options = { + a: 'b', + c: 'd', + gaxOpts: { + e: 'f' + }, + autoPaginate: false + }; + var expectedOptions = extend({}, options, { project: 'projects/' + pubsub.projectId }); - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'listSnapshots'); - assert.deepEqual(reqOpts, expectedOptions); - assert.deepEqual(options, originalOptions); + var expectedGaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + delete expectedOptions.gaxOpts; + delete expectedOptions.autoPaginate; + + pubsub.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'listSnapshots'); + assert.deepEqual(config.reqOpts, expectedOptions); + assert.deepEqual(config.gaxOpts, expectedGaxOpts); done(); }; @@ -354,54 +716,32 @@ describe('PubSub', function() { }); }); - it('should return a query if more results exist', function() { - var token = 'next-page-token'; - - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, { nextPageToken: token }); - }; - - var query = { pageSize: 1 }; - - pubsub.getSnapshots(query, function(err, snapshots, nextQuery) { - assert.ifError(err); - assert.strictEqual(query.pageSize, nextQuery.pageSize); - assert.equal(query.pageToken, token); - }); - }); - - it('should pass error if api returns an error', function(done) { - var error = new Error('Error'); + it('should pass back all parameters', function(done) { + var err_ = new Error('abc'); + var snapshots_ = null; + var nextQuery_ = {}; + var apiResponse_ = {}; - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(error); - }; - - pubsub.getSnapshots(function(err) { - assert.equal(err, error); - done(); - }); - }); - - it('should pass apiResponse to callback', function(done) { - var resp = { success: true }; - - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); + pubsub.request = function(config, callback) { + callback(err_, snapshots_, nextQuery_, apiResponse_); }; pubsub.getSnapshots(function(err, snapshots, nextQuery, apiResponse) { - assert.ifError(err); - assert.equal(resp, apiResponse); + assert.strictEqual(err, err_); + assert.deepEqual(snapshots, snapshots_); + assert.strictEqual(nextQuery, nextQuery_); + assert.strictEqual(apiResponse, apiResponse_); done(); }); }); }); describe('getSubscriptions', function() { + var apiResponse = { subscriptions: [{ name: 'fake-subscription' }] }; + beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, { subscriptions: [{ name: 'fake-subscription' }] }); + pubsub.request = function(config, callback) { + callback(null, apiResponse.subscriptions, {}, apiResponse); }; }); @@ -414,49 +754,35 @@ describe('PubSub', function() { }); it('should pass the correct arguments to the API', function(done) { - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'listSubscriptions'); - assert.strictEqual(reqOpts.project, 'projects/' + pubsub.projectId); - done(); + var options = { + gaxOpts: { + a: 'b' + }, + autoPaginate: false }; - pubsub.getSubscriptions(assert.ifError); - }); + var expectedGaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); - describe('topics', function() { - var TOPIC; - var TOPIC_NAME = 'topic'; + var project = 'projects/' + pubsub.projectId; - beforeEach(function() { - TOPIC = new Topic(pubsub, TOPIC_NAME); - }); - - it('should subscribe to a topic by string', function(done) { - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Publisher'); - assert.strictEqual(protoOpts.method, 'listTopicSubscriptions'); - assert.strictEqual(reqOpts.topic, TOPIC_NAME); - done(); - }; - - pubsub.getSubscriptions({ topic: TOPIC_NAME }, assert.ifError); - }); - - it('should subscribe to a topic by Topic instance', function(done) { - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(reqOpts.topic, TOPIC.name); - done(); - }; + pubsub.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'listSubscriptions'); + assert.deepEqual(config.reqOpts, { project: project }); + assert.deepEqual(config.gaxOpts, expectedGaxOpts); + done(); + }; - pubsub.getSubscriptions({ topic: TOPIC }, assert.ifError); - }); + pubsub.getSubscriptions(options, assert.ifError); }); it('should pass options to API request', function(done) { var opts = { pageSize: 10, pageToken: 'abc' }; - pubsub.request = function(protoOpts, reqOpts) { + pubsub.request = function(config) { + var reqOpts = config.reqOpts; assert.strictEqual(reqOpts.pageSize, opts.pageSize); assert.strictEqual(reqOpts.pageToken, opts.pageToken); done(); @@ -465,74 +791,69 @@ describe('PubSub', function() { pubsub.getSubscriptions(opts, assert.ifError); }); - it('should pass error & response if api returns an error', function(done) { - var error = new Error('Error'); - var resp = { error: true }; + it('should return Subscription instances', function(done) { + pubsub.getSubscriptions(function(err, subscriptions) { + assert.ifError(err); + assert(subscriptions[0] instanceof SubscriptionCached); + done(); + }); + }); + + it('should pass back all params', function(done) { + var err_ = new Error('err'); + var subs_ = false; + var nextQuery_ = {}; + var apiResponse_ = {}; - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(error, resp); + pubsub.request = function(config, callback) { + callback(err_, subs_, nextQuery_, apiResponse_); }; pubsub.getSubscriptions(function(err, subs, nextQuery, apiResponse) { - assert.equal(err, error); - assert.deepEqual(apiResponse, resp); + assert.strictEqual(err, err_); + assert.deepEqual(subs, subs_); + assert.strictEqual(nextQuery, nextQuery_); + assert.strictEqual(apiResponse, apiResponse_); done(); }); }); - describe('returning Subscription instances', function() { - it('should handle subscriptions.list response', function(done) { - pubsub.getSubscriptions(function(err, subscriptions) { - assert.ifError(err); - assert(subscriptions[0] instanceof SubscriptionCached); - done(); - }); - }); + describe('with topic', function() { + var TOPIC_NAME = 'topic-name'; - it('should handle topics.subscriptions.list response', function(done) { - var subName = 'sub-name'; - var subFullName = - 'projects/' + PROJECT_ID + '/subscriptions/' + subName; + it('should call topic.getSubscriptions', function(done) { + var topic = new FakeTopic(); - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, { subscriptions: [subName] }); + var opts = { + topic: topic }; - pubsub.getSubscriptions(function(err, subscriptions) { - assert.ifError(err); - assert(subscriptions[0] instanceof SubscriptionCached); - assert.equal(subscriptions[0].name, subFullName); - done(); - }); - }); - }); - - it('should return a query if more results exist', function() { - var token = 'next-page-token'; - - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, { nextPageToken: token }); - }; - - var query = { maxResults: 1 }; + topic.getSubscriptions = function(options, callback) { + assert.strictEqual(options, opts); + callback(); // the done fn + }; - pubsub.getSubscriptions(query, function(err, subscriptions, nextQuery) { - assert.ifError(err); - assert.strictEqual(query.maxResults, nextQuery.maxResults); - assert.equal(query.pageToken, token); + pubsub.getSubscriptions(opts, done); }); - }); - it('should pass apiResponse to callback', function(done) { - var resp = { success: true }; + it('should create a topic instance from a name', function(done) { + var opts = { + topic: TOPIC_NAME + }; - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); - }; + var fakeTopic = { + getSubscriptions: function(options, callback) { + assert.strictEqual(options, opts); + callback(); // the done fn + } + }; - pubsub.getSubscriptions(function(err, subs, nextQuery, apiResponse) { - assert.equal(resp, apiResponse); - done(); + pubsub.topic = function(name) { + assert.strictEqual(name, TOPIC_NAME); + return fakeTopic; + }; + + pubsub.getSubscriptions(opts, done); }); }); }); @@ -542,8 +863,8 @@ describe('PubSub', function() { var apiResponse = { topics: [{ name: topicName }]}; beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); + pubsub.request = function(config, callback) { + callback(null, apiResponse.topics, {}, apiResponse); }; }); @@ -556,20 +877,35 @@ describe('PubSub', function() { }); it('should build the right request', function(done) { - var options = { a: 'b', c: 'd' }; - var originalOptions = extend({}, options); + var options = { + a: 'b', + c: 'd', + gaxOpts: { + e: 'f' + }, + autoPaginate: false + }; + var expectedOptions = extend({}, options, { project: 'projects/' + pubsub.projectId }); - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Publisher'); - assert.strictEqual(protoOpts.method, 'listTopics'); - assert.deepEqual(reqOpts, expectedOptions); - assert.deepEqual(options, originalOptions); + var expectedGaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + delete expectedOptions.gaxOpts; + delete expectedOptions.autoPaginate; + + pubsub.request = function(config) { + assert.strictEqual(config.client, 'publisherClient'); + assert.strictEqual(config.method, 'listTopics'); + assert.deepEqual(config.reqOpts, expectedOptions); + assert.deepEqual(config.gaxOpts, expectedGaxOpts); done(); }; - pubsub.getTopics(options, function() {}); + + pubsub.getTopics(options, assert.ifError); }); it('should return Topic instances with metadata', function(done) { @@ -588,289 +924,114 @@ describe('PubSub', function() { }); }); - it('should return a query if more results exist', function() { - var token = 'next-page-token'; - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, { nextPageToken: token }); - }; - var query = { pageSize: 1 }; - pubsub.getTopics(query, function(err, topics, nextQuery) { - assert.ifError(err); - assert.strictEqual(query.pageSize, nextQuery.pageSize); - assert.equal(query.pageToken, token); - }); - }); + it('should pass back all params', function(done) { + var err_ = new Error('err'); + var topics_ = false; + var nextQuery_ = {}; + var apiResponse_ = {}; - it('should pass error if api returns an error', function() { - var error = new Error('Error'); - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(error); + pubsub.request = function(config, callback) { + callback(err_, topics_, nextQuery_, apiResponse_); }; - pubsub.getTopics(function(err) { - assert.equal(err, error); - }); - }); - it('should pass apiResponse to callback', function(done) { - var resp = { success: true }; - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); - }; pubsub.getTopics(function(err, topics, nextQuery, apiResponse) { - assert.equal(resp, apiResponse); + assert.strictEqual(err, err_); + assert.deepEqual(topics, topics_); + assert.strictEqual(nextQuery, nextQuery_); + assert.strictEqual(apiResponse, apiResponse_); done(); }); }); }); - describe('subscribe', function() { - var TOPIC_NAME = 'topic'; - var TOPIC = { - name: 'projects/' + PROJECT_ID + '/topics/' + TOPIC_NAME - }; - - var SUB_NAME = 'subscription'; - var SUBSCRIPTION = { - name: 'projects/' + PROJECT_ID + '/subscriptions/' + SUB_NAME - }; - - var apiResponse = { - name: 'subscription-name' + describe('request', function() { + var CONFIG = { + client: 'fakeClient', + method: 'fakeMethod', + reqOpts: { a: 'a' }, + gaxOpts: {} }; - it('should throw if no Topic is provided', function() { - assert.throws(function() { - pubsub.subscribe(); - }, /A Topic is required for a new subscription\./); - }); - - it('should not require a subscription name', function(done) { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); - }; - - pubsub.subscribe(TOPIC_NAME, done); - }); - - it('should not require a sub name and accept options', function(done) { - var opts = {}; - - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); + beforeEach(function() { + pubsub.auth = { + getProjectId: function(callback) { + callback(null, PROJECT_ID); + } }; - pubsub.subscribe(TOPIC_NAME, opts, done); - }); - - it('should not require configuration options', function(done) { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); + pubsub.api = { + fakeClient: { + fakeMethod: function(reqOpts, gaxOpts, callback) { + callback(); // in most cases, the done fn + } + } }; - pubsub.subscribe(TOPIC_NAME, SUB_NAME, done); - }); - - it('should allow undefined/optional configuration options', function(done) { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); + fakeUtil.replaceProjectIdToken = function(reqOpts) { + return reqOpts; }; - - pubsub.subscribe(TOPIC_NAME, SUB_NAME, undefined, done); }); - it('should create a Subscription', function(done) { - var opts = { a: 'b', c: 'd' }; - - pubsub.request = util.noop; - - pubsub.subscription = function(subName, options) { - assert.strictEqual(subName, SUB_NAME); - assert.deepEqual(options, opts); - setImmediate(done); - return SUBSCRIPTION; + it('should get the project id', function(done) { + pubsub.auth.getProjectId = function(callback) { + assert.strictEqual(typeof callback, 'function'); + done(); }; - pubsub.subscribe(TOPIC_NAME, SUB_NAME, opts, assert.ifError); + pubsub.request(CONFIG, assert.ifError); }); - it('should create a Topic object from a string', function(done) { - pubsub.request = util.noop; + it('should return auth errors to the callback', function(done) { + var error = new Error('err'); - pubsub.topic = function(topicName) { - assert.strictEqual(topicName, TOPIC_NAME); - setImmediate(done); - return TOPIC; + pubsub.auth.getProjectId = function(callback) { + callback(error); }; - pubsub.subscribe(TOPIC_NAME, SUB_NAME, assert.ifError); + pubsub.request(CONFIG, function(err) { + assert.strictEqual(err, error); + done(); + }); }); - it('should send correct request', function(done) { - pubsub.topic = function(topicName) { - return { - name: topicName - }; - }; - - pubsub.subscription = function(subName) { - return { - name: subName - }; - }; - - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'createSubscription'); - assert.strictEqual(protoOpts.timeout, pubsub.timeout); - assert.strictEqual(reqOpts.topic, TOPIC_NAME); - assert.strictEqual(reqOpts.name, SUB_NAME); + it('should replace the project id token on reqOpts', function(done) { + fakeUtil.replaceProjectIdToken = function(reqOpts, projectId) { + assert.deepEqual(reqOpts, CONFIG.reqOpts); + assert.strictEqual(projectId, PROJECT_ID); done(); }; - pubsub.subscribe(TOPIC_NAME, SUB_NAME, assert.ifError); + pubsub.request(CONFIG, assert.ifError); }); - it('should pass options to the api request', function(done) { - var options = { - ackDeadlineSeconds: 90, - autoAck: true, - encoding: 'utf-8', - interval: 3, - maxInProgress: 5, - retainAckedMessages: true, - pushEndpoint: 'https://domain/push', - timeout: 30000 - }; - - var expectedBody = extend({ - topic: TOPIC_NAME, - name: SUB_NAME - }, options, { - pushConfig: { - pushEndpoint: options.pushEndpoint + it('should instantiate the client lazily', function(done) { + var fakeClientInstance = { + fakeMethod: function(reqOpts, gaxOpts, callback) { + assert.strictEqual(pubsub.api.fakeClient, fakeClientInstance); + callback(); // the done function } - }); - - delete expectedBody.autoAck; - delete expectedBody.encoding; - delete expectedBody.interval; - delete expectedBody.maxInProgress; - delete expectedBody.pushEndpoint; - delete expectedBody.timeout; - - pubsub.topic = function() { - return { - name: TOPIC_NAME - }; }; - pubsub.subscription = function() { + v1Override = function(options) { + assert.strictEqual(options, pubsub.options); + return { - name: SUB_NAME + fakeClient: function(options) { + assert.strictEqual(options, pubsub.options); + return fakeClientInstance; + } }; }; - pubsub.request = function(protoOpts, reqOpts) { - assert.notStrictEqual(reqOpts, options); - assert.deepEqual(reqOpts, expectedBody); - done(); - }; - - pubsub.subscribe(TOPIC_NAME, SUB_NAME, options, assert.ifError); - }); - - describe('message retention', function() { - it('should accept a number', function(done) { - var threeDaysInSeconds = 3 * 24 * 60 * 60; - - pubsub.request = function(protoOpts, reqOpts) { - assert.strictEqual(reqOpts.retainAckedMessages, true); - - assert.strictEqual( - reqOpts.messageRetentionDuration.seconds, - threeDaysInSeconds - ); - - assert.strictEqual(reqOpts.messageRetentionDuration.nanos, 0); - - done(); - }; - - pubsub.subscribe(TOPIC_NAME, SUB_NAME, { - messageRetentionDuration: threeDaysInSeconds - }, assert.ifError); - }); - }); - - describe('error', function() { - var error = new Error('Error.'); - var apiResponse = { name: SUB_NAME }; - - beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(error, apiResponse); - }; - }); - - it('should re-use existing subscription', function(done) { - var apiResponse = { code: 409 }; - - pubsub.subscription = function() { - return SUBSCRIPTION; - }; - - pubsub.request = function(protoOpts, reqOpts, callback) { - callback({ code: 409 }, apiResponse); - }; - - pubsub.subscribe(TOPIC_NAME, SUB_NAME, function(err, subscription) { - assert.ifError(err); - assert.strictEqual(subscription, SUBSCRIPTION); - done(); - }); - }); - - it('should return error & API response to the callback', function(done) { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(error, apiResponse); - }; - - pubsub.subscribe(TOPIC_NAME, SUB_NAME, function(err, sub, resp) { - assert.strictEqual(err, error); - assert.strictEqual(sub, null); - assert.strictEqual(resp, apiResponse); - done(); - }); - }); + delete pubsub.api.fakeClient; + pubsub.request(CONFIG, done); }); - describe('success', function() { - var apiResponse = { name: SUB_NAME }; - - beforeEach(function() { - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); - }; - }); - - it('should return Subscription & resp to the callback', function(done) { - var subscription = {}; - - pubsub.subscription = function() { - return subscription; - }; - - pubsub.request = function(protoOpts, reqOpts, callback) { - callback(null, apiResponse); - }; - - pubsub.subscribe(TOPIC_NAME, SUB_NAME, function(err, sub, resp) { - assert.ifError(err); - assert.strictEqual(sub, subscription); - assert.strictEqual(resp, apiResponse); - done(); - }); - }); + it('should do nothing if sandbox env var is set', function(done) { + global.GCLOUD_SANDBOX_ENV = true; + pubsub.request(CONFIG, done); // should not fire done + global.GCLOUD_SANDBOX_ENV = false; + done(); }); }); @@ -894,7 +1055,7 @@ describe('PubSub', function() { describe('subscription', function() { var SUB_NAME = 'new-sub-name'; - var CONFIG = { autoAck: true, interval: 90 }; + var CONFIG = {}; it('should return a Subscription object', function() { SubscriptionOverride = function() {}; @@ -903,53 +1064,25 @@ describe('PubSub', function() { }); it('should pass specified name to the Subscription', function(done) { - SubscriptionOverride = function(pubsub, options) { - assert.equal(options.name, SUB_NAME); + SubscriptionOverride = function(pubsub, name) { + assert.equal(name, SUB_NAME); done(); }; - pubsub.subscription(SUB_NAME, {}); + pubsub.subscription(SUB_NAME); }); it('should honor settings', function(done) { - SubscriptionOverride = function(pubsub, options) { - assert.deepEqual(options, CONFIG); + SubscriptionOverride = function(pubsub, name, options) { + assert.strictEqual(options, CONFIG); done(); }; pubsub.subscription(SUB_NAME, CONFIG); }); - it('should not require a name', function(done) { - SubscriptionOverride = function(pubsub, options) { - assert.deepEqual(options, { - name: undefined - }); - done(); - }; - - pubsub.subscription(); - }); - - it('should not require a name and accept options', function(done) { - SubscriptionOverride = function(pubsub, options) { - var expectedOptions = extend({}, CONFIG); - expectedOptions.name = undefined; - - assert.deepEqual(options, expectedOptions); - done(); - }; - - pubsub.subscription(CONFIG); - }); - - it('should not require options', function(done) { - SubscriptionOverride = function(pubsub, options) { - assert.deepEqual(options, { - name: SUB_NAME - }); - done(); - }; - - pubsub.subscription(SUB_NAME); + it('should throw if a name is not provided', function() { + assert.throws(function() { + return pubsub.subscription(); + }, /A name must be specified for a subscription\./); }); }); @@ -957,170 +1090,11 @@ describe('PubSub', function() { it('should throw if a name is not provided', function() { assert.throws(function() { pubsub.topic(); - }, /A name must be specified for a new topic\./); + }, /A name must be specified for a topic\./); }); it('should return a Topic object', function() { - assert(pubsub.topic('new-topic') instanceof Topic); - }); - }); - - describe('request', function() { - var TIMEOUT = Math.random(); - - beforeEach(function() { - GAX_CONFIG_PUBLISHER_OVERRIDE.methods = { - MethodName: { - timeout_millis: TIMEOUT - } - }; - }); - - after(function() { - GAX_CONFIG_PUBLISHER_OVERRIDE.methods = {}; - }); - - it('should pass through the request', function(done) { - var args = [ - { - service: 'Publisher', - method: 'MethodName' - }, - { - value: true - }, - { - anotherValue: true - } - ]; - - grpcServiceRequestOverride = function() { - assert.strictEqual(this, pubsub); - assert.strictEqual(args[0], arguments[0]); - assert.strictEqual(args[1], arguments[1]); - assert.strictEqual(args[2], arguments[2]); - done(); - }; - - pubsub.request.apply(pubsub, args); - }); - - it('should assign a timeout', function(done) { - grpcServiceRequestOverride = function(protoOpts) { - assert.strictEqual(protoOpts.timeout, TIMEOUT); - done(); - }; - - pubsub.request({ - service: 'Publisher', - method: 'MethodName' - }); - }); - - it('should not override a timeout if set', function(done) { - var timeout = 0; - - grpcServiceRequestOverride = function(protoOpts) { - assert.strictEqual(protoOpts.timeout, timeout); - done(); - }; - - pubsub.request({ - service: 'Publisher', - method: 'MethodName', - timeout: timeout - }); - }); - - it('should camel case the method name', function(done) { - grpcServiceRequestOverride = function(protoOpts) { - assert.strictEqual(protoOpts.timeout, TIMEOUT); - done(); - }; - - pubsub.request({ - service: 'Publisher', - method: 'methodName' - }); - }); - }); - - describe('determineBaseUrl_', function() { - function setHost(host) { - process.env.PUBSUB_EMULATOR_HOST = host; - } - - beforeEach(function() { - delete process.env.PUBSUB_EMULATOR_HOST; - }); - - it('should set base url to parameter sent', function() { - var defaultBaseUrl_ = 'defaulturl'; - var testingUrl = 'localhost:8085'; - - setHost(defaultBaseUrl_); - pubsub.defaultBaseUrl_ = defaultBaseUrl_; - - pubsub.determineBaseUrl_(testingUrl); - assert.strictEqual(pubsub.baseUrl_, testingUrl); - }); - - it('should default to defaultBaseUrl_', function() { - var defaultBaseUrl_ = 'defaulturl'; - pubsub.defaultBaseUrl_ = defaultBaseUrl_; - - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.baseUrl_, defaultBaseUrl_); - }); - - it('should remove slashes from the baseUrl', function() { - var expectedBaseUrl = 'localhost:8080'; - - setHost('localhost:8080/'); - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.baseUrl_, expectedBaseUrl); - - setHost('localhost:8080//'); - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.baseUrl_, expectedBaseUrl); - }); - - it('should remove the protocol if specified', function() { - setHost('http://localhost:8080'); - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.baseUrl_, 'localhost:8080'); - - setHost('https://localhost:8080'); - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.baseUrl_, 'localhost:8080'); - }); - - it('should not set customEndpoint_ when using default baseurl', function() { - var pubsub = new PubSub({ projectId: PROJECT_ID }); - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.customEndpoint_, undefined); - }); - - describe('with PUBSUB_EMULATOR_HOST environment variable', function() { - var PUBSUB_EMULATOR_HOST = 'localhost:9090'; - - beforeEach(function() { - setHost(PUBSUB_EMULATOR_HOST); - }); - - after(function() { - delete process.env.PUBSUB_EMULATOR_HOST; - }); - - it('should use the PUBSUB_EMULATOR_HOST env var', function() { - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.baseUrl_, PUBSUB_EMULATOR_HOST); - }); - - it('should set customEndpoint_', function() { - pubsub.determineBaseUrl_(); - assert.strictEqual(pubsub.customEndpoint_, true); - }); + assert(pubsub.topic('new-topic') instanceof FakeTopic); }); }); }); diff --git a/packages/pubsub/test/publisher.js b/packages/pubsub/test/publisher.js new file mode 100644 index 00000000000..d5e0487b24a --- /dev/null +++ b/packages/pubsub/test/publisher.js @@ -0,0 +1,329 @@ +/** + * Copyright 2017 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. + */ + +'use strict'; + +var assert = require('assert'); +var common = require('@google-cloud/common'); +var extend = require('extend'); +var proxyquire = require('proxyquire'); + +var promisified = false; +var fakeUtil = extend({}, common.util, { + promisifyAll: function(Class) { + if (Class.name === 'Publisher') { + promisified = true; + } + } +}); + +describe('Publisher', function() { + var Publisher; + var publisher; + var batchOpts; + + var TOPIC_NAME = 'test-topic'; + var TOPIC = { + name: TOPIC_NAME, + request: fakeUtil.noop + }; + + before(function() { + Publisher = proxyquire('../src/publisher.js', { + '@google-cloud/common': { + util: fakeUtil + } + }); + }); + + beforeEach(function() { + TOPIC.request = fakeUtil.noop; + publisher = new Publisher(TOPIC); + batchOpts = publisher.settings.batching; + }); + + describe('initialization', function() { + it('should promisify all the things', function() { + assert(promisified); + }); + + it('should localize the topic object', function() { + assert.strictEqual(publisher.topic, TOPIC); + }); + + it('should create an inventory object', function() { + assert.deepEqual(publisher.inventory_, { + callbacks: [], + queued: [], + bytes: 0 + }); + }); + + describe('options', function() { + it('should provide default values for batching', function() { + assert.deepEqual(publisher.settings.batching, { + maxBytes: Math.pow(1024, 2) * 5, + maxMessages: 1000, + maxMilliseconds: 1000 + }); + }); + + it('should capture user specified options', function() { + var options = { + maxBytes: 10, + maxMessages: 11, + maxMilliseconds: 12 + }; + var optionsCopy = extend({}, options); + + var publisher = new Publisher(TOPIC, { + batching: options + }); + + assert.deepEqual(publisher.settings.batching, options); + assert.deepEqual(options, optionsCopy); + }); + + it('should cap maxBytes', function() { + var expected = Math.pow(1024, 2) * 9; + + var publisher = new Publisher(TOPIC, { + batching: { maxBytes: expected + 1024 } + }); + + assert.strictEqual(publisher.settings.batching.maxBytes, expected); + }); + + it('should cap maxMessages', function() { + var publisher = new Publisher(TOPIC, { + batching: { maxMessages: 2000 } + }); + + assert.strictEqual(publisher.settings.batching.maxMessages, 1000); + }); + }); + }); + + describe('publish', function() { + var DATA = new Buffer('hello'); + var ATTRS = { a: 'a' }; + + var globalSetTimeout; + + before(function() { + globalSetTimeout = global.setTimeout; + }); + + beforeEach(function() { + publisher.publish_ = fakeUtil.noop; + global.setTimeout = fakeUtil.noop; + }); + + after(function() { + global.setTimeout = globalSetTimeout; + }); + + it('should throw an error when data is not a buffer', function() { + assert.throws(function() { + publisher.publish('hello', {}, fakeUtil.noop); + }, /Data must be in the form of a Buffer\./); + }); + + it('should queue the data', function(done) { + publisher.queue_ = function(data, attrs, callback) { + assert.strictEqual(data, DATA); + assert.strictEqual(attrs, ATTRS); + callback(); // the done fn + }; + + publisher.publish(DATA, ATTRS, done); + }); + + it('should optionally accept attributes', function(done) { + publisher.queue_ = function(data, attrs, callback) { + assert.strictEqual(data, DATA); + assert.deepEqual(attrs, {}); + callback(); // the done fn + }; + + publisher.publish(DATA, done); + }); + + it('should publish if data puts payload size over cap', function(done) { + var queueCalled = false; + + publisher.publish_ = function() { + assert.strictEqual(queueCalled, false); + publisher.inventory_.bytes = 0; + }; + + publisher.queue_ = function(data, attrs, callback) { + assert.strictEqual(publisher.inventory_.bytes, 0); + queueCalled = true; + callback(); // the done fn + }; + + publisher.inventory_.bytes = batchOpts.maxBytes - 1; + publisher.publish(DATA, done); + }); + + it('should publish if data puts payload at size cap', function(done) { + publisher.queue_ = function() { + publisher.inventory_.bytes += DATA.length; + }; + + publisher.publish_ = done; + publisher.inventory_.bytes = batchOpts.maxBytes - DATA.length; + publisher.publish(DATA, fakeUtil.noop); + }); + + it('should publish if data puts payload at message cap', function(done) { + var queueCalled = false; + + publisher.queue_ = function() { + queueCalled = true; + }; + + publisher.publish_ = function() { + assert(queueCalled); + done(); + }; + + publisher.inventory_.queued = Array(batchOpts.maxMessages).fill({}); + publisher.publish(DATA, fakeUtil.noop); + }); + + it('should set a timeout if a publish did not occur', function(done) { + var globalSetTimeout = global.setTimeout; + var fakeTimeoutHandle = 12345; + + global.setTimeout = function(callback, duration) { + assert.strictEqual(duration, batchOpts.maxMilliseconds); + global.setTimeout = globalSetTimeout; + setImmediate(callback); + return fakeTimeoutHandle; + }; + + publisher.publish_ = done; + publisher.publish(DATA, fakeUtil.noop); + + assert.strictEqual(publisher.timeoutHandle_, fakeTimeoutHandle); + }); + + it('should not set a timeout if one exists', function() { + var fakeTimeoutHandle = 'not-a-real-handle'; + + publisher.timeoutHandle_ = 'not-a-real-handle'; + publisher.publish(DATA, fakeUtil.noop); + assert.strictEqual(publisher.timeoutHandle_, fakeTimeoutHandle); + }); + }); + + describe('publish_', function() { + it('should cancel any publish timeouts', function(done) { + publisher.timeoutHandle_ = setTimeout(done, 1); + publisher.publish_(); + assert.strictEqual(publisher.timeoutHandle_, null); + done(); + }); + + it('should reset the inventory object', function() { + publisher.inventory_.callbacks.push(fakeUtil.noop); + publisher.inventory_.queued.push({}); + publisher.inventory_.bytes = 5; + + publisher.publish_(); + + assert.deepEqual(publisher.inventory_.callbacks, []); + assert.deepEqual(publisher.inventory_.queued, []); + assert.strictEqual(publisher.inventory_.bytes, 0); + }); + + it('should make the correct request', function(done) { + var FAKE_MESSAGE = {}; + + TOPIC.request = function(config) { + assert.strictEqual(config.client, 'publisherClient'); + assert.strictEqual(config.method, 'publish'); + assert.deepEqual(config.reqOpts, { + topic: TOPIC_NAME, + messages: [FAKE_MESSAGE] + }); + done(); + }; + + publisher.inventory_.queued.push(FAKE_MESSAGE); + publisher.publish_(); + }); + + it('should pass back the err/msg id to correct callback', function(done) { + var error = new Error('err'); + var FAKE_IDS = ['abc', 'def']; + var callbackCalls = 0; + + publisher.inventory_.callbacks = [ + function(err, messageId) { + assert.strictEqual(err, error); + assert.strictEqual(messageId, FAKE_IDS[0]); + callbackCalls += 1; + }, + function(err, messageId) { + assert.strictEqual(err, error); + assert.strictEqual(messageId, FAKE_IDS[1]); + callbackCalls += 1; + }, + function(err, messageId) { + assert.strictEqual(err, error); + assert.strictEqual(messageId, undefined); + assert.strictEqual(callbackCalls, 2); + done(); + } + ]; + + TOPIC.request = function(config, callback) { + callback(error, { messageIds: FAKE_IDS }); + }; + + publisher.publish_(); + }); + }); + + describe('queue_', function() { + var DATA = new Buffer('hello'); + var ATTRS = { a: 'a' }; + + it('should add the data and attrs to the inventory', function() { + publisher.queue_(DATA, ATTRS, fakeUtil.noop); + + assert.deepEqual(publisher.inventory_.queued, [{ + data: DATA, + attributes: ATTRS + }]); + }); + + it('should update the inventory size', function() { + publisher.queue_(DATA, ATTRS, fakeUtil.noop); + + assert.strictEqual(publisher.inventory_.bytes, DATA.length); + }); + + it('should capture the callback', function() { + publisher.queue_(DATA, ATTRS, fakeUtil.noop); + + assert.deepEqual(publisher.inventory_.callbacks, [fakeUtil.noop]); + }); + }); +}); diff --git a/packages/pubsub/test/snapshot.js b/packages/pubsub/test/snapshot.js index d97f76a6559..0e9f1d711eb 100644 --- a/packages/pubsub/test/snapshot.js +++ b/packages/pubsub/test/snapshot.js @@ -17,14 +17,22 @@ 'use strict'; var assert = require('assert'); +var common = require('@google-cloud/common'); +var extend = require('extend'); var proxyquire = require('proxyquire'); -function FakeGrpcServiceObject() { - this.calledWith_ = arguments; -} +var promisified = false; +var fakeUtil = extend({}, common.util, { + promisifyAll: function(Class) { + if (Class.name === 'Snapshot') { + promisified = true; + } + } +}); describe('Snapshot', function() { var Snapshot; + var snapshot; var SNAPSHOT_NAME = 'a'; var PROJECT_ID = 'grape-spaceship-123'; @@ -34,19 +42,26 @@ describe('Snapshot', function() { }; var SUBSCRIPTION = { - parent: PUBSUB, + pubsub: PUBSUB, + projectId: PROJECT_ID, + api: {}, createSnapshot: function() {}, seek: function() {} }; before(function() { Snapshot = proxyquire('../src/snapshot.js', { - '@google-cloud/common-grpc': { - ServiceObject: FakeGrpcServiceObject + '@google-cloud/common': { + util: fakeUtil } }); }); + beforeEach(function() { + fakeUtil.noop = function() {}; + snapshot = new Snapshot(SUBSCRIPTION, SNAPSHOT_NAME); + }); + describe('initialization', function() { var FULL_SNAPSHOT_NAME = 'a/b/c/d'; var formatName_; @@ -62,6 +77,14 @@ describe('Snapshot', function() { Snapshot.formatName_ = formatName_; }); + it('should promisify all the things', function() { + assert(promisified); + }); + + it('should localize the parent', function() { + assert.strictEqual(snapshot.parent, SUBSCRIPTION); + }); + describe('name', function() { it('should create and cache the full name', function() { Snapshot.formatName_ = function(projectId, name) { @@ -70,11 +93,11 @@ describe('Snapshot', function() { return FULL_SNAPSHOT_NAME; }; - var snapshot = new Snapshot(PUBSUB, SNAPSHOT_NAME); + var snapshot = new Snapshot(SUBSCRIPTION, SNAPSHOT_NAME); assert.strictEqual(snapshot.name, FULL_SNAPSHOT_NAME); }); - it('should pull the projectId from subscription parent', function() { + it('should pull the projectId from parent object', function() { Snapshot.formatName_ = function(projectId, name) { assert.strictEqual(projectId, PROJECT_ID); assert.strictEqual(name, SNAPSHOT_NAME); @@ -86,37 +109,15 @@ describe('Snapshot', function() { }); }); - it('should inherit from GrpcServiceObject', function() { - var snapshot = new Snapshot(PUBSUB, SNAPSHOT_NAME); - var calledWith = snapshot.calledWith_[0]; - - assert(snapshot instanceof FakeGrpcServiceObject); - assert.strictEqual(calledWith.parent, PUBSUB); - assert.strictEqual(calledWith.id, FULL_SNAPSHOT_NAME); - assert.deepEqual(calledWith.methods, { - delete: { - protoOpts: { - service: 'Subscriber', - method: 'deleteSnapshot' - }, - reqOpts: { - snapshot: FULL_SNAPSHOT_NAME - } - } - }); - }); - describe('with Subscription parent', function() { it('should include the create method', function(done) { - SUBSCRIPTION.createSnapshot = function(callback) { + SUBSCRIPTION.createSnapshot = function(name, callback) { + assert.strictEqual(name, SNAPSHOT_NAME); callback(); // The done function }; var snapshot = new Snapshot(SUBSCRIPTION, SNAPSHOT_NAME); - var calledWith = snapshot.calledWith_[0]; - - assert(calledWith.methods.create); - calledWith.createMethod(done); + snapshot.create(done); }); it('should create a seek method', function(done) { @@ -129,6 +130,22 @@ describe('Snapshot', function() { snapshot.seek(done); }); }); + + describe('with PubSub parent', function() { + var snapshot; + + beforeEach(function() { + snapshot = new Snapshot(PUBSUB, SNAPSHOT_NAME); + }); + + it('should not include the create method', function() { + assert.strictEqual(snapshot.create, undefined); + }); + + it('should not include a seek method', function() { + assert.strictEqual(snapshot.seek, undefined); + }); + }); }); describe('formatName_', function() { @@ -146,4 +163,27 @@ describe('Snapshot', function() { assert.strictEqual(name, EXPECTED); }); }); + + describe('delete', function() { + it('should make the correct request', function(done) { + snapshot.parent.request = function(config, callback) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'deleteSnapshot'); + assert.deepEqual(config.reqOpts, { snapshot: snapshot.name }); + callback(); // the done fn + }; + + snapshot.delete(done); + }); + + it('should optionally accept a callback', function(done) { + fakeUtil.noop = done; + + snapshot.parent.request = function(config, callback) { + callback(); // the done fn + }; + + snapshot.delete(); + }); + }); }); diff --git a/packages/pubsub/test/subscription.js b/packages/pubsub/test/subscription.js index a84bc9b9c2d..63fa029b502 100644 --- a/packages/pubsub/test/subscription.js +++ b/packages/pubsub/test/subscription.js @@ -17,21 +17,41 @@ 'use strict'; var assert = require('assert'); +var common = require('@google-cloud/common'); +var events = require('events'); var extend = require('extend'); +var is = require('is'); var proxyquire = require('proxyquire'); -var util = require('@google-cloud/common').util; +var util = require('util'); + +var FAKE_FREE_MEM = 168222720; +var fakeOs = { + freemem: function() { + return FAKE_FREE_MEM; + } +}; var promisified = false; -var fakeUtil = extend({}, util, { - promisifyAll: function(Class) { - if (Class.name === 'Subscription') { - promisified = true; +var fakeUtil = extend({}, common.util, { + promisifyAll: function(Class, options) { + if (Class.name !== 'Subscription') { + return; } + + promisified = true; + assert.deepEqual(options.exclude, ['snapshot']); } }); -function FakeGrpcServiceObject() { - this.calledWith_ = arguments; +function FakeConnectionPool() { + this.calledWith_ = [].slice.call(arguments); + events.EventEmitter.call(this); +} + +util.inherits(FakeConnectionPool, events.EventEmitter); + +function FakeHistogram() { + this.calledWith_ = [].slice.call(arguments); } function FakeIAM() { @@ -39,11 +59,9 @@ function FakeIAM() { } function FakeSnapshot() { - this.calledWith_ = arguments; + this.calledWith_ = [].slice.call(arguments); } -var formatMessageOverride; - describe('Subscription', function() { var Subscription; var subscription; @@ -51,36 +69,10 @@ describe('Subscription', function() { var PROJECT_ID = 'test-project'; var SUB_NAME = 'test-subscription'; var SUB_FULL_NAME = 'projects/' + PROJECT_ID + '/subscriptions/' + SUB_NAME; + var PUBSUB = { projectId: PROJECT_ID, - request: util.noop - }; - var message = 'howdy'; - var messageBuffer = new Buffer(message).toString('base64'); - var messageBinary = new Buffer(message).toString('binary'); - var messageObj = { - receivedMessages: [{ - ackId: 'abc', - message: { - data: messageBuffer, - messageId: 7 - } - }] - }; - var expectedMessage = { - ackId: 'abc', - data: message, - id: 7 - }; - var expectedMessageAsBinary = { - ackId: 'abc', - data: messageBinary, - id: 7 - }; - var expectedMessageAsBase64 = { - ackId: 'abc', - data: messageBuffer, - id: 7 + request: fakeUtil.noop }; before(function() { @@ -88,27 +80,17 @@ describe('Subscription', function() { '@google-cloud/common': { util: fakeUtil }, - '@google-cloud/common-grpc': { - ServiceObject: FakeGrpcServiceObject - }, + os: fakeOs, + './connection-pool.js': FakeConnectionPool, + './histogram.js': FakeHistogram, './iam.js': FakeIAM, './snapshot.js': FakeSnapshot }); - - var formatMessage = Subscription.formatMessage_; - Subscription.formatMessage_ = function() { - return (formatMessageOverride || formatMessage).apply(null, arguments); - }; }); beforeEach(function() { - subscription = new Subscription(PUBSUB, { name: SUB_NAME }); - PUBSUB.request = util.noop; - subscription.parent = PUBSUB; - }); - - afterEach(function() { - formatMessageOverride = null; + PUBSUB.request = fakeUtil.noop = function() {}; + subscription = new Subscription(PUBSUB, SUB_NAME); }); describe('initialization', function() { @@ -116,247 +98,123 @@ describe('Subscription', function() { assert(promisified); }); - describe('name', function() { - var FORMATTED_NAME = 'formatted-name'; - var GENERATED_NAME = 'generated-name'; - - var formatName_; - var generateName_; - - before(function() { - formatName_ = Subscription.formatName_; - generateName_ = Subscription.generateName_; - - Subscription.formatName_ = function() { - return FORMATTED_NAME; - }; - - Subscription.generateName_ = function() { - return GENERATED_NAME; - }; - }); - - afterEach(function() { - Subscription.formatName_ = function() { - return FORMATTED_NAME; - }; - - Subscription.generateName_ = function() { - return GENERATED_NAME; - }; - }); - - after(function() { - Subscription.formatName_ = formatName_; - Subscription.generateName_ = generateName_; - }); - - it('should generate name', function(done) { - Subscription.formatName_ = function(projectId, name) { - assert.strictEqual(name, GENERATED_NAME); - done(); - }; - - new Subscription(PUBSUB, {}); - }); - - it('should format name', function() { - Subscription.formatName_ = function(projectId, name) { - assert.strictEqual(projectId, PROJECT_ID); - assert.strictEqual(name, SUB_NAME); - return FORMATTED_NAME; - }; - - var subscription = new Subscription(PUBSUB, { name: SUB_NAME }); - assert.strictEqual(subscription.name, FORMATTED_NAME); - }); - }); - - it('should honor configuration settings', function() { - var CONFIG = { - name: SUB_NAME, - autoAck: true, - interval: 100, - maxInProgress: 3, - encoding: 'binary', - timeout: 30000 - }; - var sub = new Subscription(PUBSUB, CONFIG); - assert.strictEqual(sub.autoAck, CONFIG.autoAck); - assert.strictEqual(sub.interval, CONFIG.interval); - assert.strictEqual(sub.encoding, CONFIG.encoding); - assert.strictEqual(sub.maxInProgress, CONFIG.maxInProgress); - assert.strictEqual(sub.timeout, CONFIG.timeout); + it('should localize the pubsub object', function() { + assert.strictEqual(subscription.pubsub, PUBSUB); }); - it('should be closed', function() { - assert.strictEqual(subscription.closed, true); + it('should localize the project id', function() { + assert.strictEqual(subscription.projectId, PROJECT_ID); }); - it('should default autoAck to false if not specified', function() { - assert.strictEqual(subscription.autoAck, false); - }); + it('should localize pubsub request method', function(done) { + PUBSUB.request = function(callback) { + callback(); // the done fn + }; - it('should set default interval if one is not specified', function() { - assert.equal(subscription.interval, 10); + var subscription = new Subscription(PUBSUB, SUB_NAME); + subscription.request(done); }); - it('should start inProgressAckIds as an empty object', function() { - assert.deepEqual(subscription.inProgressAckIds, {}); + it('should create a histogram instance', function() { + assert(subscription.histogram instanceof FakeHistogram); }); - it('should default maxInProgress to Infinity if not specified', function() { - assert.strictEqual(subscription.maxInProgress, Infinity); - }); + it('should format the sub name', function() { + var formattedName = 'a/b/c/d'; + var formatName = Subscription.formatName_; - it('should set messageListeners to 0', function() { - assert.strictEqual(subscription.messageListeners, 0); - }); + Subscription.formatName_ = function(projectId, name) { + assert.strictEqual(projectId, PROJECT_ID); + assert.strictEqual(name, SUB_NAME); - it('should not be paused', function() { - assert.strictEqual(subscription.paused, false); - }); + Subscription.formatName_ = formatName; - it('should default encoding to utf-8 if not specified', function() { - assert.strictEqual(subscription.encoding, 'utf-8'); - }); + return formattedName; + }; - it('should default timeout to 92 seconds', function() { - assert.strictEqual(subscription.timeout, 92000); + var subscription = new Subscription(PUBSUB, SUB_NAME); + assert.strictEqual(subscription.name, formattedName); }); - it('should create an iam object', function() { - assert.deepEqual(subscription.iam.calledWith_, [ - PUBSUB, - SUB_FULL_NAME - ]); - }); + it('should honor configuration settings', function() { + var options = { + maxConnections: 2, + flowControl: { + maxBytes: 5, + maxMessages: 10 + } + }; - it('should inherit from GrpcServiceObject', function() { - assert(subscription instanceof FakeGrpcServiceObject); + var subscription = new Subscription(PUBSUB, SUB_NAME, options); - var calledWith = subscription.calledWith_[0]; + assert.strictEqual(subscription.maxConnections, options.maxConnections); - assert.strictEqual(calledWith.parent, PUBSUB); - assert.strictEqual(calledWith.id, SUB_FULL_NAME); - assert.deepEqual(calledWith.methods, { - exists: true, - get: true, - getMetadata: { - protoOpts: { - service: 'Subscriber', - method: 'getSubscription' - }, - reqOpts: { - subscription: subscription.name - } - } + assert.deepEqual(subscription.flowControl, { + maxBytes: options.flowControl.maxBytes, + maxMessages: options.flowControl.maxMessages }); }); - it('should allow creating if it is a Topic', function(done) { - var topicInstance = {}; + it('should set sensible defaults', function() { + assert.strictEqual(subscription.ackDeadline, 10000); + assert.strictEqual(subscription.maxConnections, 5); + assert.strictEqual(subscription.userClosed_, false); + assert.strictEqual(subscription.messageListeners, 0); - var pubSubInstance = extend({}, PUBSUB, { - subscribe: { - bind: function(context, topic) { - assert.strictEqual(context, pubSubInstance); - assert.strictEqual(topic, topicInstance); - done(); - } - } + assert.deepEqual(subscription.flowControl, { + maxBytes: FAKE_FREE_MEM * 0.2, + maxMessages: Infinity }); + }); - var subscription = new Subscription(pubSubInstance, { - name: SUB_NAME, - topic: topicInstance - }); + it('should create an inventory object', function() { + assert(is.object(subscription.inventory_)); + assert(is.array(subscription.inventory_.lease)); + assert(is.array(subscription.inventory_.ack)); + assert(is.array(subscription.inventory_.nack)); + assert.strictEqual(subscription.inventory_.bytes, 0); + }); - var calledWith = subscription.calledWith_[0]; - assert.deepEqual(calledWith.methods.create, true); + it('should inherit from EventEmitter', function() { + assert(subscription instanceof events.EventEmitter); }); - }); - describe('formatMessage_', function() { - it('should decode stringified JSON to object', function() { - var obj = { hi: 'there' }; - var stringified = new Buffer(JSON.stringify(obj)).toString('base64'); - var attributes = {}; - var publishTime = { - seconds: '1480413405', - nanos: 617000000 + it('should make a create method if a topic is found', function(done) { + var TOPIC_NAME = 'test-topic'; + + PUBSUB.createSubscription = function(topic, subName, callback) { + assert.strictEqual(topic, TOPIC_NAME); + assert.strictEqual(subName, SUB_NAME); + callback(); // the done function }; - var seconds = parseInt(publishTime.seconds, 10); - var milliseconds = parseInt(publishTime.nanos, 10) / 1e6; - var expectedDate = new Date(seconds * 1000 + milliseconds); - - var msg = Subscription.formatMessage_({ - ackId: 'abc', - message: { - data: stringified, - messageId: 7, - attributes: attributes, - publishTime: publishTime - } + var subscription = new Subscription(PUBSUB, SUB_NAME, { + topic: TOPIC_NAME }); - assert.deepEqual(msg, { - ackId: 'abc', - id: 7, - data: obj, - attributes: attributes, - timestamp: expectedDate - }); + subscription.create(done); }); - it('should work if nanos is 0', function() { - var obj = { hi: 'there' }; - var stringified = new Buffer(JSON.stringify(obj)).toString('base64'); - var attributes = {}; - var publishTime = { - seconds: '1480413405', - nanos: 0 - }; + it('should create an IAM object', function() { + assert(subscription.iam instanceof FakeIAM); - var seconds = parseInt(publishTime.seconds, 10); - var milliseconds = parseInt(publishTime.nanos, 10) / 1e6; - var expectedDate = new Date(seconds * 1000 + milliseconds); - - var msg = Subscription.formatMessage_({ - ackId: 'abc', - message: { - data: stringified, - messageId: 7, - attributes: attributes, - publishTime: publishTime - } - }); + var args = subscription.iam.calledWith_; - assert.deepEqual(msg, { - ackId: 'abc', - id: 7, - data: obj, - attributes: attributes, - timestamp: expectedDate - }); + assert.strictEqual(args[0], PUBSUB); + assert.strictEqual(args[1], subscription.name); }); - it('should decode buffer to string', function() { - var msg = Subscription.formatMessage_(messageObj.receivedMessages[0]); - assert.deepEqual(msg, expectedMessage); - }); + it('should listen for events', function() { + var called = false; + var listenForEvents = Subscription.prototype.listenForEvents_; - it('should decode buffer to base64', function() { - var msg = Subscription - .formatMessage_(messageObj.receivedMessages[0], 'base64'); - assert.deepEqual(msg, expectedMessageAsBase64); - }); + Subscription.prototype.listenForEvents_ = function() { + Subscription.prototype.listenForEvents_ = listenForEvents; + called = true; + }; - it('should decode buffer to specified encoding', function() { - var msg = Subscription - .formatMessage_(messageObj.receivedMessages[0], 'binary'); - assert.deepEqual(msg, expectedMessageAsBinary); + new Subscription(PUBSUB, SUB_NAME); + assert(called); }); }); @@ -372,922 +230,1531 @@ describe('Subscription', function() { }); }); - describe('ack', function() { - it('should throw if no IDs are provided', function() { - assert.throws(function() { - subscription.ack(); - }, /At least one ID must be specified before it can be acknowledged\./); - assert.throws(function() { - subscription.ack([]); - }, /At least one ID must be specified before it can be acknowledged\./); - }); - - it('should accept a single id', function() { - assert.doesNotThrow(function() { - subscription.ack(1, util.noop); - }); - }); + describe('ack_', function() { + var MESSAGE = { + ackId: 'abc', + received: 12345, + connectionId: 'def' + }; - it('should accept an array of ids', function() { - assert.doesNotThrow(function() { - subscription.ack([1], util.noop); - }); + beforeEach(function() { + subscription.setFlushTimeout_ = fakeUtil.noop; + subscription.breakLease_ = fakeUtil.noop; + subscription.histogram.add = fakeUtil.noop; }); - it('should make an array out of ids', function(done) { - var ID = 'abc'; - - subscription.parent.request = function(protoOpts, reqOpts) { - assert.deepEqual(reqOpts.ackIds, [ID]); + it('should break the lease on the message', function(done) { + subscription.breakLease_ = function(message) { + assert.strictEqual(message, MESSAGE); done(); }; - subscription.ack(ID, assert.ifError); + subscription.ack_(MESSAGE); }); - it('should make correct api request', function(done) { - var IDS = [1, 2, 3]; - - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'acknowledge'); - - assert.strictEqual(reqOpts.subscription, subscription.name); - assert.strictEqual(reqOpts.ackIds, IDS); - - done(); - }; - - subscription.ack(IDS, assert.ifError); - }); + it('should add the time it took to ack to the histogram', function(done) { + var fakeNow = 12381832; + var now = global.Date.now; - it('should honor the timeout setting', function(done) { - var options = { - timeout: 10 + global.Date.now = function() { + global.Date.now = now; + return fakeNow; }; - subscription.parent.request = function(protoOpts) { - assert.strictEqual(protoOpts.timeout, options.timeout); + subscription.histogram.add = function(time) { + assert.strictEqual(time, fakeNow - MESSAGE.received); done(); }; - subscription.ack('abc', options, assert.ifError); + subscription.ack_(MESSAGE); }); - it('should not require a callback', function() { - assert.doesNotThrow(function() { - subscription.ack('abc'); - subscription.ack('abc', { - timeout: 10 - }); + describe('without connection pool', function() { + it('should store the ack id in the inventory object', function(done) { + subscription.setFlushTimeout_ = function() { + assert.deepEqual(subscription.inventory_.ack, [MESSAGE.ackId]); + done(); + }; + + subscription.ack_(MESSAGE); }); }); - it('should unmark the ack ids as being in progress', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(); - }; - - subscription.inProgressAckIds = { id1: true, id2: true, id3: true }; + describe('with connection pool', function() { + var pool; - subscription.ack(['id1', 'id2'], function(err) { - assert.ifError(err); + beforeEach(function() { + subscription.setFlushTimeout_ = function() { + throw new Error('Should not be called.'); + }; - var inProgressAckIds = subscription.inProgressAckIds; - assert.strictEqual(inProgressAckIds.id1, undefined); - assert.strictEqual(inProgressAckIds.id2, undefined); - assert.strictEqual(inProgressAckIds.id3, true); + pool = { + isConnected: function() { + return true; + } + }; - done(); + subscription.connectionPool = pool; }); - }); - - it('should not unmark if there was an error', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(new Error('Error.')); - }; - subscription.inProgressAckIds = { id1: true, id2: true, id3: true }; + it('should set a timeout if pool is not connected', function(done) { + subscription.setFlushTimeout_ = function() { + assert.deepEqual(subscription.inventory_.ack, [MESSAGE.ackId]); + done(); + }; - subscription.ack(['id1', 'id2'], function() { - var inProgressAckIds = subscription.inProgressAckIds; - assert.strictEqual(inProgressAckIds.id1, true); - assert.strictEqual(inProgressAckIds.id2, true); - assert.strictEqual(inProgressAckIds.id3, true); + pool.isConnected = function() { + return false; + }; - done(); + subscription.ack_(MESSAGE); }); - }); - it('should refresh paused status', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(); - }; + it('should write to the connection it came in on', function(done) { + var fakeConnection = { + write: function(data) { + assert.deepEqual(data, { ackIds: [MESSAGE.ackId] }); + done(); + } + }; - subscription.refreshPausedStatus_ = done; + pool.acquire = function(connectionId, callback) { + assert.strictEqual(connectionId, MESSAGE.connectionId); + callback(null, fakeConnection); + }; - subscription.ack(1, assert.ifError); - }); + subscription.ack_(MESSAGE); + }); - it('should pass error to callback', function(done) { - var error = new Error('Error.'); + it('should emit an error when unable to get a conn', function(done) { + var error = new Error('err'); - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(error); - }; + pool.acquire = function(connectionId, callback) { + callback(error); + }; - subscription.ack(1, function(err) { - assert.strictEqual(err, error); - done(); - }); - }); + subscription.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); - it('should pass apiResponse to callback', function(done) { - var resp = { success: true }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); - }; - subscription.ack(1, function(err, apiResponse) { - assert.deepEqual(resp, apiResponse); - done(); + subscription.ack_(MESSAGE); }); }); }); - describe('createSnapshot', function() { - var SNAPSHOT_NAME = 'a'; + describe('breakLease_', function() { + var MESSAGE = { + ackId: 'abc', + data: new Buffer('hello'), + length: 5 + }; - it('should throw if a name is not provided', function() { - assert.throws(function() { - subscription.createSnapshot(); - }, /A name is required to create a snapshot\./); + beforeEach(function() { + subscription.inventory_.lease.push(MESSAGE.ackId); + subscription.inventory_.bytes += MESSAGE.length; }); - it('should make the correct api request', function(done) { - var FULL_SNAPSHOT_NAME = 'a/b/c/d'; + it('should remove the message from the lease array', function() { + assert.strictEqual(subscription.inventory_.lease.length, 1); + assert.strictEqual(subscription.inventory_.bytes, MESSAGE.length); - FakeSnapshot.formatName_ = function(projectId, name) { - assert.strictEqual(projectId, PROJECT_ID); - assert.strictEqual(name, SNAPSHOT_NAME); - return FULL_SNAPSHOT_NAME; - }; + subscription.breakLease_(MESSAGE); - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'createSnapshot'); + assert.strictEqual(subscription.inventory_.lease.length, 0); + assert.strictEqual(subscription.inventory_.bytes, 0); + }); - assert.strictEqual(reqOpts.name, FULL_SNAPSHOT_NAME); - assert.strictEqual(reqOpts.subscription, subscription.name); + describe('with connection pool', function() { + it('should resume receiving messages if paused', function(done) { + subscription.connectionPool = { + isPaused: true, + resume: done + }; - done(); - }; + subscription.hasMaxMessages_ = function() { + return false; + }; - subscription.createSnapshot(SNAPSHOT_NAME, assert.ifError); - }); + subscription.breakLease_(MESSAGE); + }); - it('should return an error to the callback', function(done) { - var error = new Error('err'); - var resp = {}; + it('should not resume if it is not paused', function() { + subscription.connectionPool = { + isPaused: false, + resume: function() { + throw new Error('Should not be called.'); + } + }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(error, resp); - }; + subscription.hasMaxMessages_ = function() { + return false; + }; - function callback(err, snapshot, apiResponse) { - assert.strictEqual(err, error); - assert.strictEqual(snapshot, null); - assert.strictEqual(apiResponse, resp); - done(); - } + subscription.breakLease_(MESSAGE); + }); - subscription.createSnapshot(SNAPSHOT_NAME, callback); - }); + it('should not resume if the max message limit is hit', function() { + subscription.connectionPool = { + isPaused: true, + resume: function() { + throw new Error('Should not be called.'); + } + }; - it('should return a snapshot object to the callback', function(done) { - var fakeSnapshot = {}; - var resp = {}; + subscription.hasMaxMessages_ = function() { + return true; + }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); - }; + subscription.breakLease_(MESSAGE); + }); + }); - subscription.snapshot = function(name) { - assert.strictEqual(name, SNAPSHOT_NAME); - return fakeSnapshot; - }; + it('should quit auto-leasing if all leases are gone', function(done) { + subscription.leaseTimeoutHandle_ = setTimeout(done, 1); + subscription.breakLease_(MESSAGE); - function callback(err, snapshot, apiResponse) { - assert.strictEqual(err, null); - assert.strictEqual(snapshot, fakeSnapshot); - assert.strictEqual(snapshot.metadata, resp); - assert.strictEqual(apiResponse, resp); - done(); - } + assert.strictEqual(subscription.leaseTimeoutHandle_, null); + setImmediate(done); + }); + + it('should continue to auto-lease if leases exist', function(done) { + subscription.inventory_.lease.push(MESSAGE.ackId); + subscription.inventory_.lease.push('abcd'); - subscription.createSnapshot(SNAPSHOT_NAME, callback); + subscription.leaseTimeoutHandle_ = setTimeout(done, 1); + subscription.breakLease_(MESSAGE); }); }); - describe('delete', function() { - it('should delete a subscription', function(done) { - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'deleteSubscription'); + describe('close', function() { + it('should set the userClosed_ flag', function() { + subscription.close(); - assert.strictEqual(reqOpts.subscription, subscription.name); + assert.strictEqual(subscription.userClosed_, true); + }); - done(); - }; + it('should stop auto-leasing', function(done) { + subscription.leaseTimeoutHandle_ = setTimeout(done, 1); + subscription.close(); - subscription.delete(); + assert.strictEqual(subscription.leaseTimeoutHandle_, null); + setImmediate(done); }); - it('should close a subscription once deleted', function() { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(); - }; - subscription.closed = false; - subscription.delete(); - assert.strictEqual(subscription.closed, true); + it('should stop any queued flushes', function(done) { + subscription.flushTimeoutHandle_ = setTimeout(done, 1); + subscription.close(); + + assert.strictEqual(subscription.flushTimeoutHandle_, null); + setImmediate(done); }); - it('should remove all listeners', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(); - }; - subscription.removeAllListeners = function() { - done(); - }; - subscription.delete(); + it('should flush immediately', function(done) { + subscription.flushQueues_ = done; + subscription.close(); }); - it('should execute callback when deleted', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(); + it('should call closeConnection_', function(done) { + subscription.closeConnection_ = function(callback) { + callback(); // the done fn }; - subscription.delete(done); + + subscription.close(done); }); + }); - it('should execute callback with an api error', function(done) { - var error = new Error('Error.'); - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(error); - }; - subscription.delete(function(err) { - assert.equal(err, error); - done(); + describe('closeConnection_', function() { + afterEach(function() { + fakeUtil.noop = function() {}; + }); + + describe('with connection pool', function() { + beforeEach(function() { + subscription.connectionPool = { + close: function(callback) { + setImmediate(callback); // the done fn + } + }; + }); + + it('should call close on the connection pool', function(done) { + subscription.closeConnection_(done); + assert.strictEqual(subscription.connectionPool, null); + }); + + it('should use a noop when callback is absent', function(done) { + fakeUtil.noop = done; + subscription.closeConnection_(); + assert.strictEqual(subscription.connectionPool, null); }); }); - it('should execute callback with apiResponse', function(done) { - var resp = { success: true }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); - }; - subscription.delete(function(err, apiResponse) { - assert.deepEqual(resp, apiResponse); - done(); + describe('without connection pool', function() { + beforeEach(function() { + subscription.connectionPool = null; + }); + + it('should exec the callback if one is passed in', function(done) { + subscription.closeConnection_(done); + }); + + it('should optionally accept a callback', function() { + subscription.closeConnection_(); }); }); }); - describe('pull', function() { + describe('createSnapshot', function() { + var SNAPSHOT_NAME = 'test-snapshot'; + beforeEach(function() { - subscription.ack = util.noop; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, messageObj); + subscription.snapshot = function(name) { + return { + name: name + }; }; }); - it('should not require configuration options', function(done) { - subscription.pull(done); + it('should throw an error if a snapshot name is not found', function() { + assert.throws(function() { + subscription.createSnapshot(); + }, /A name is required to create a snapshot\./); }); - it('should default returnImmediately to false', function(done) { - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(reqOpts.returnImmediately, false); + it('should make the correct request', function(done) { + subscription.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'createSnapshot'); + assert.deepEqual(config.reqOpts, { + name: SNAPSHOT_NAME, + subscription: subscription.name + }); done(); }; - subscription.pull({}, assert.ifError); + + subscription.createSnapshot(SNAPSHOT_NAME, assert.ifError); }); - it('should honor options', function(done) { - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(reqOpts.returnImmediately, true); + it('should optionally accept gax options', function(done) { + var gaxOpts = {}; + + subscription.request = function(config) { + assert.strictEqual(config.gaxOpts, gaxOpts); done(); }; - subscription.pull({ returnImmediately: true }, assert.ifError); - }); - it('should make correct api request', function(done) { - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'pull'); - assert.strictEqual(protoOpts.timeout, 92000); + subscription.createSnapshot(SNAPSHOT_NAME, gaxOpts, assert.ifError); + }); - assert.strictEqual(reqOpts.subscription, subscription.name); - assert.strictEqual(reqOpts.returnImmediately, false); - assert.strictEqual(reqOpts.maxMessages, 1); + it('should pass back any errors to the callback', function(done) { + var error = new Error('err'); + var apiResponse = {}; - done(); + subscription.request = function(config, callback) { + callback(error, apiResponse); }; - subscription.pull({ maxResults: 1 }, assert.ifError); + subscription.createSnapshot(SNAPSHOT_NAME, function(err, snapshot, resp) { + assert.strictEqual(err, error); + assert.strictEqual(snapshot, null); + assert.strictEqual(resp, apiResponse); + done(); + }); }); - it('should pass a timeout if specified', function(done) { - var timeout = 30000; + it('should return a snapshot object with metadata', function(done) { + var apiResponse = {}; + var fakeSnapshot = {}; - var subscription = new Subscription(PUBSUB, { - name: SUB_NAME, - timeout: timeout - }); + subscription.snapshot = function() { + return fakeSnapshot; + }; - subscription.parent = { - request: function(protoOpts) { - assert.strictEqual(protoOpts.timeout, 30000); - done(); - } + subscription.request = function(config, callback) { + callback(null, apiResponse); }; - subscription.pull(assert.ifError); + subscription.createSnapshot(SNAPSHOT_NAME, function(err, snapshot, resp) { + assert.ifError(err); + assert.strictEqual(snapshot, fakeSnapshot); + assert.strictEqual(snapshot.metadata, apiResponse); + assert.strictEqual(resp, apiResponse); + done(); + }); }); + }); - it('should store the active request', function() { - var requestInstance = {}; - - subscription.parent.request = function() { - return requestInstance; + describe('delete', function() { + it('should make the correct request', function(done) { + subscription.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'deleteSubscription'); + assert.deepEqual(config.reqOpts, { subscription: subscription.name }); + done(); }; - subscription.pull(assert.ifError); - assert.strictEqual(subscription.activeRequest_, requestInstance); + subscription.delete(assert.ifError); }); - it('should clear the active request', function(done) { - var requestInstance = {}; - - subscription.parent.request = function(protoOpts, reqOpts, callback) { - setImmediate(function() { - callback(null, {}); - assert.strictEqual(subscription.activeRequest_, null); - done(); - }); + it('should optionally accept gax options', function(done) { + var gaxOpts = {}; - return requestInstance; + subscription.request = function(config) { + assert.strictEqual(config.gaxOpts, gaxOpts); + done(); }; - subscription.pull(assert.ifError); + subscription.delete(gaxOpts, assert.ifError); }); - it('should pass error to callback', function(done) { - var error = new Error('Error.'); - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(error); - }; - subscription.pull(function(err) { - assert.equal(err, error); - done(); + describe('success', function() { + var apiResponse = {}; + + beforeEach(function() { + subscription.request = function(config, callback) { + callback(null, apiResponse); + }; + }); + + it('should optionally accept a callback', function(done) { + fakeUtil.noop = function(err, resp) { + assert.ifError(err); + assert.strictEqual(resp, apiResponse); + done(); + }; + + subscription.delete(); + }); + + it('should return the api response', function(done) { + subscription.delete(function(err, resp) { + assert.ifError(err); + assert.strictEqual(resp, apiResponse); + done(); + }); + }); + + it('should remove all message listeners', function(done) { + var called = false; + + subscription.removeAllListeners = function() { + called = true; + }; + + subscription.delete(function(err) { + assert.ifError(err); + assert(called); + done(); + }); + }); + + it('should close the subscription', function(done) { + var called = false; + + subscription.close = function() { + called = true; + }; + + subscription.delete(function(err) { + assert.ifError(err); + assert(called); + done(); + }); + }); + }); + + describe('error', function() { + var error = new Error('err'); + + beforeEach(function() { + subscription.request = function(config, callback) { + callback(error); + }; + }); + + it('should return the error to the callback', function(done) { + subscription.delete(function(err) { + assert.strictEqual(err, error); + done(); + }); + }); + + it('should not remove all the listeners', function(done) { + subscription.removeAllListeners = function() { + done(new Error('Should not be called.')); + }; + + subscription.delete(function() { + done(); + }); + }); + + it('should not close the subscription', function(done) { + subscription.close = function() { + done(new Error('Should not be called.')); + }; + + subscription.delete(function() { + done(); + }); }); }); + }); - it('should not return messages if request timed out', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback({ code: 504 }); + describe('exists', function() { + it('should return true if it finds metadata', function(done) { + subscription.getMetadata = function(callback) { + callback(null, {}); }; - subscription.pull({}, function(err, messages) { + subscription.exists(function(err, exists) { assert.ifError(err); - assert.deepEqual(messages, []); + assert(exists); done(); }); }); - it('should call formatMessage_ with encoding', function(done) { - subscription.encoding = 'encoding-value'; - - formatMessageOverride = function(msg, encoding) { - assert.strictEqual(msg, messageObj.receivedMessages[0]); - assert.strictEqual(encoding, subscription.encoding); - setImmediate(done); - return msg; + it('should return false if a not found error occurs', function(done) { + subscription.getMetadata = function(callback) { + callback({ code: 5 }); }; - subscription.pull({}, assert.ifError); + subscription.exists(function(err, exists) { + assert.ifError(err); + assert.strictEqual(exists, false); + done(); + }); }); - it('should decorate the message', function(done) { - subscription.decorateMessage_ = function() { - done(); + it('should pass back any other type of error', function(done) { + var error = { code: 4 }; + + subscription.getMetadata = function(callback) { + callback(error); }; - subscription.pull({}, assert.ifError); + subscription.exists(function(err, exists) { + assert.strictEqual(err, error); + assert.strictEqual(exists, undefined); + done(); + }); + }); + }); + + describe('flushQueues_', function() { + beforeEach(function() { + subscription.inventory_.ack = ['abc', 'def']; + subscription.inventory_.nack = ['ghi', 'jkl']; }); - it('should refresh paused status', function(done) { - subscription.refreshPausedStatus_ = function() { - done(); + it('should do nothing if theres nothing to ack/nack', function() { + subscription.inventory_.ack = []; + subscription.inventory_.nack = []; + + subscription.connectionPool = { + acquire: function() { + throw new Error('Should not be called.'); + } }; - subscription.pull({}, assert.ifError); + subscription.flushQueues_(); }); - describe('autoAck false', function() { + describe('with connection pool', function() { + var fakeConnection; + beforeEach(function() { - subscription.autoAck = false; - }); + fakeConnection = { + write: fakeUtil.noop + }; - it('should not ack', function() { - subscription.ack = function() { - throw new Error('Should not have acked.'); + subscription.connectionPool = { + acquire: function(callback) { + callback(null, fakeConnection); + } }; - subscription.pull({}, assert.ifError); }); - it('should execute callback with message', function(done) { - subscription.decorateMessage_ = function(msg) { return msg; }; - subscription.pull({}, function(err, msgs) { - assert.ifError(err); - assert.deepEqual(msgs, [expectedMessage]); + it('should emit any connection acquiring errors', function(done) { + var error = new Error('err'); + + subscription.connectionPool.acquire = function(callback) { + callback(error); + }; + + subscription.on('error', function(err) { + assert.strictEqual(err, error); done(); }); + + subscription.flushQueues_(); }); - it('should pass apiResponse to callback', function(done) { - subscription.pull(function(err, msgs, apiResponse) { - assert.ifError(err); - assert.strictEqual(apiResponse, messageObj); + it('should write the acks to the connection', function(done) { + fakeConnection.write = function(reqOpts) { + assert.deepEqual(reqOpts, { + ackIds: ['abc', 'def'] + }); + done(); + }; + + subscription.inventory_.nack = []; + subscription.flushQueues_(); + }); + + it('should write the nacks to the connection', function(done) { + fakeConnection.write = function(reqOpts) { + assert.deepEqual(reqOpts, { + modifyDeadlineAckIds: ['ghi', 'jkl'], + modifyDeadlineSeconds: [0, 0] + }); done(); + }; + + subscription.inventory_.ack = []; + subscription.flushQueues_(); + }); + + it('should clear the inventory after writing', function() { + subscription.flushQueues_(); + + assert.strictEqual(subscription.inventory_.ack.length, 0); + assert.strictEqual(subscription.inventory_.nack.length, 0); + }); + }); + + describe('without connection pool', function() { + describe('acking', function() { + beforeEach(function() { + subscription.inventory_.nack = []; + }); + + it('should make the correct request', function(done) { + subscription.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'acknowledge'); + assert.deepEqual(config.reqOpts, { + subscription: subscription.name, + ackIds: ['abc', 'def'] + }); + done(); + }; + + subscription.flushQueues_(); + }); + + it('should emit any request errors', function(done) { + var error = new Error('err'); + + subscription.request = function(config, callback) { + callback(error); + }; + + subscription.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); + + subscription.flushQueues_(); + }); + + it('should clear the inventory on success', function(done) { + subscription.request = function(config, callback) { + callback(null); + assert.strictEqual(subscription.inventory_.ack.length, 0); + done(); + }; + + subscription.flushQueues_(); }); }); + + describe('nacking', function() { + beforeEach(function() { + subscription.inventory_.ack = []; + }); + + it('should make the correct request', function(done) { + subscription.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'modifyAckDeadline'); + assert.deepEqual(config.reqOpts, { + subscription: subscription.name, + ackIds: ['ghi', 'jkl'], + ackDeadlineSeconds: 0 + }); + done(); + }; + + subscription.flushQueues_(); + }); + + it('should emit any request errors', function(done) { + var error = new Error('err'); + + subscription.request = function(config, callback) { + callback(error); + }; + + subscription.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); + + subscription.flushQueues_(); + }); + + it('should clear the inventory on success', function(done) { + subscription.request = function(config, callback) { + callback(null); + assert.strictEqual(subscription.inventory_.nack.length, 0); + done(); + }; + + subscription.flushQueues_(); + }); + }); + }); + }); + + describe('get', function() { + beforeEach(function() { + subscription.create = fakeUtil.noop; + }); + + it('should delete the autoCreate option', function(done) { + var options = { + autoCreate: true, + a: 'a' + }; + + subscription.getMetadata = function(gaxOpts) { + assert.strictEqual(gaxOpts, options); + assert.strictEqual(gaxOpts.autoCreate, undefined); + done(); + }; + + subscription.get(options, assert.ifError); }); - describe('autoAck true', function() { + describe('success', function() { + var fakeMetadata = {}; + beforeEach(function() { - subscription.autoAck = true; - subscription.ack = function(id, callback) { - callback(); + subscription.getMetadata = function(gaxOpts, callback) { + callback(null, fakeMetadata); }; }); - it('should ack', function(done) { - subscription.ack = function() { + it('should call through to getMetadata', function(done) { + subscription.get(function(err, sub, resp) { + assert.ifError(err); + assert.strictEqual(sub, subscription); + assert.strictEqual(resp, fakeMetadata); done(); - }; - subscription.pull({}, assert.ifError); + }); }); - it('should not autoAck if no messages returned', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, { receivedMessages: [] }); + it('should optionally accept options', function(done) { + var options = {}; + + subscription.getMetadata = function(gaxOpts, callback) { + assert.strictEqual(gaxOpts, options); + callback(); // the done fn }; - subscription.ack = function() { - throw new Error('I should not run.'); + + subscription.get(options, done); + }); + }); + + describe('error', function() { + it('should pass back errors when not auto-creating', function(done) { + var error = { code: 4 }; + var apiResponse = {}; + + subscription.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); }; - subscription.pull(function() { + + subscription.get(function(err, sub, resp) { + assert.strictEqual(err, error); + assert.strictEqual(sub, null); + assert.strictEqual(resp, apiResponse); done(); }); }); - it('should pass id to ack', function(done) { - subscription.ack = function(id) { - assert.equal(id, expectedMessage.ackId); - done(); + it('should pass back 404 errors if autoCreate is false', function(done) { + var error = { code: 5 }; + var apiResponse = {}; + + subscription.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); }; - subscription.pull({}, assert.ifError); - }); - it('should pass callback to ack', function(done) { - subscription.pull({}, done); + subscription.get(function(err, sub, resp) { + assert.strictEqual(err, error); + assert.strictEqual(sub, null); + assert.strictEqual(resp, apiResponse); + done(); + }); }); - it('should invoke callback with error from ack', function(done) { - var error = new Error('Error.'); - subscription.ack = function(id, callback) { - callback(error); + it('should pass back 404 errors if create doesnt exist', function(done) { + var error = { code: 5 }; + var apiResponse = {}; + + subscription.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); }; - subscription.pull({}, function(err) { - assert.equal(err, error); + + delete subscription.create; + + subscription.get(function(err, sub, resp) { + assert.strictEqual(err, error); + assert.strictEqual(sub, null); + assert.strictEqual(resp, apiResponse); done(); }); }); - it('should execute callback', function(done) { - subscription.pull({}, done); - }); + it('should create the sub if 404 + autoCreate is true', function(done) { + var error = { code: 5 }; + var apiResponse = {}; - it('should return pull response as apiResponse', function(done) { - var resp = { - receivedMessages: [{ - ackId: 1, - message: { - messageId: 'abc', - data: new Buffer('message').toString('base64') - } - }] + var fakeOptions = { + autoCreate: true }; - subscription.ack = function(id, callback) { - callback(null, { success: true }); + subscription.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); + subscription.create = function(options, callback) { + assert.strictEqual(options, fakeOptions); + callback(); // the done fn }; - subscription.pull({}, function(err, msgs, apiResponse) { - assert.deepEqual(resp, apiResponse); - done(); - }); + subscription.get(fakeOptions, done); }); }); }); - describe('seek', function() { - it('should throw if a name or date is not provided', function() { - assert.throws(function() { - subscription.seek(); - }, /Either a snapshot name or Date is needed to seek to\./); + describe('getMetadata', function() { + it('should make the correct request', function(done) { + subscription.request = function(config) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'getSubscription'); + assert.deepEqual(config.reqOpts, { subscription: subscription.name }); + done(); + }; + + subscription.getMetadata(assert.ifError); }); - it('should make the correct api request', function(done) { - var FAKE_SNAPSHOT_NAME = 'a'; - var FAKE_FULL_SNAPSHOT_NAME = 'a/b/c/d'; + it('should optionally accept gax options', function(done) { + var gaxOpts = {}; - FakeSnapshot.formatName_ = function(projectId, name) { - assert.strictEqual(projectId, PROJECT_ID); - assert.strictEqual(name, FAKE_SNAPSHOT_NAME); - return FAKE_FULL_SNAPSHOT_NAME; + subscription.request = function(config) { + assert.strictEqual(config.gaxOpts, gaxOpts); + done(); }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'seek'); + subscription.getMetadata(gaxOpts, assert.ifError); + }); - assert.strictEqual(reqOpts.subscription, subscription.name); - assert.strictEqual(reqOpts.snapshot, FAKE_FULL_SNAPSHOT_NAME); + it('should pass back any errors that occur', function(done) { + var error = new Error('err'); + var apiResponse = {}; - // done function - callback(); + subscription.request = function(config, callback) { + callback(error, apiResponse); }; - subscription.seek(FAKE_SNAPSHOT_NAME, done); + subscription.getMetadata(function(err, metadata) { + assert.strictEqual(err, error); + assert.strictEqual(metadata, apiResponse); + done(); + }); }); - it('should optionally accept a Date object', function(done) { - var date = new Date(); - - subscription.parent.request = function(protoOpts, reqOpts, callback) { - var seconds = Math.floor(date.getTime() / 1000); - assert.strictEqual(reqOpts.time.seconds, seconds); + it('should set the metadata if no error occurs', function(done) { + var apiResponse = {}; - var nanos = date.getMilliseconds() * 1e6; - assert.strictEqual(reqOpts.time.nanos, nanos); - - // done function - callback(); + subscription.request = function(config, callback) { + callback(null, apiResponse); }; - subscription.seek(date, done); + subscription.getMetadata(function(err, metadata) { + assert.ifError(err); + assert.strictEqual(metadata, apiResponse); + assert.strictEqual(subscription.metadata, apiResponse); + done(); + }); }); }); - describe('setAckDeadline', function() { - it('should set the ack deadline', function(done) { - subscription.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Subscriber'); - assert.strictEqual(protoOpts.method, 'modifyAckDeadline'); + describe('hasMaxMessages_', function() { + it('should return true if the number of leases == maxMessages', function() { + subscription.inventory_.lease = ['a', 'b', 'c']; + subscription.flowControl.maxMessages = 3; - assert.deepEqual(reqOpts, { - subscription: subscription.name, - ackIds: ['abc'], - ackDeadlineSeconds: 10 - }); + assert(subscription.hasMaxMessages_()); + }); - done(); - }; + it('should return true if bytes == maxBytes', function() { + subscription.inventory_.bytes = 1000; + subscription.flowControl.maxBytes = 1000; - subscription.setAckDeadline({ ackIds: ['abc'], seconds: 10 }, done); + assert(subscription.hasMaxMessages_()); }); - it('should execute the callback', function(done) { - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(); - }; - subscription.setAckDeadline({}, done); - }); + it('should return false if neither condition is met', function() { + subscription.inventory_.lease = ['a', 'b']; + subscription.flowControl.maxMessages = 3; - it('should execute the callback with apiResponse', function(done) { - var resp = { success: true }; - subscription.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); - }; - subscription.setAckDeadline({}, function(err, apiResponse) { - assert.deepEqual(resp, apiResponse); - done(); - }); + subscription.inventory_.bytes = 900; + subscription.flowControl.maxBytes = 1000; + + assert.strictEqual(subscription.hasMaxMessages_(), false); }); }); - describe('snapshot', function() { - it('should call through to pubsub#snapshot', function() { - var FAKE_SNAPSHOT_NAME = 'a'; - var FAKE_SNAPSHOT = {}; + describe('leaseMessage_', function() { + var MESSAGE = { + ackId: 'abc', + data: new Buffer('hello'), + length: 5 + }; - PUBSUB.snapshot = function(name) { - assert.strictEqual(this, subscription); - assert.strictEqual(name, FAKE_SNAPSHOT_NAME); - return FAKE_SNAPSHOT; - }; + beforeEach(function() { + subscription.setLeaseTimeout_ = fakeUtil.noop; + }); - var snapshot = subscription.snapshot(FAKE_SNAPSHOT_NAME); - assert.strictEqual(snapshot, FAKE_SNAPSHOT); + it('should add the ackId to the inventory', function() { + subscription.leaseMessage_(MESSAGE); + assert.deepEqual(subscription.inventory_.lease, [MESSAGE.ackId]); }); - }); - describe('decorateMessage_', function() { - var message = { - ackId: 'b' - }; + it('should update the byte count', function() { + assert.strictEqual(subscription.inventory_.bytes, 0); + subscription.leaseMessage_(MESSAGE); + assert.strictEqual(subscription.inventory_.bytes, MESSAGE.length); + }); + + it('should begin auto-leasing', function(done) { + subscription.setLeaseTimeout_ = done; + subscription.leaseMessage_(MESSAGE); + }); it('should return the message', function() { - var decoratedMessage = subscription.decorateMessage_(message); - assert.strictEqual(decoratedMessage.ackId, message.ackId); + var message = subscription.leaseMessage_(MESSAGE); + assert.strictEqual(message, MESSAGE); }); + }); - it('should mark the message as being in progress', function() { - subscription.decorateMessage_(message); - assert.strictEqual(subscription.inProgressAckIds[message.ackId], true); + describe('listenForEvents_', function() { + beforeEach(function() { + subscription.openConnection_ = fakeUtil.noop; + subscription.closeConnection_ = fakeUtil.noop; }); - describe('ack', function() { - it('should add an ack function to ack', function() { - var decoratedMessage = subscription.decorateMessage_(message); - assert.equal(typeof decoratedMessage.ack, 'function'); + describe('on new listener', function() { + it('should increment messageListeners', function() { + assert.strictEqual(subscription.messageListeners, 0); + subscription.on('message', fakeUtil.noop); + assert.strictEqual(subscription.messageListeners, 1); }); - it('should pass the ackId to subscription.ack', function(done) { - subscription.ack = function(ackId, callback) { - assert.strictEqual(ackId, message.ackId); - callback(); + it('should ignore non-message events', function() { + subscription.on('data', fakeUtil.noop); + assert.strictEqual(subscription.messageListeners, 0); + }); + + it('should open a connection', function(done) { + subscription.openConnection_ = done; + subscription.on('message', fakeUtil.noop); + }); + + it('should set the userClosed_ flag to false', function() { + subscription.userClosed_ = true; + subscription.on('message', fakeUtil.noop); + assert.strictEqual(subscription.userClosed_, false); + }); + + it('should not open a connection when one exists', function() { + subscription.connectionPool = {}; + + subscription.openConnection_ = function() { + throw new Error('Should not be called.'); }; - subscription.decorateMessage_(message).ack(done); + subscription.on('message', fakeUtil.noop); }); }); - describe('skip', function() { - it('should add a skip function', function() { - var decoratedMessage = subscription.decorateMessage_(message); - assert.equal(typeof decoratedMessage.skip, 'function'); + describe('on remove listener', function() { + var noop = function() {}; + + it('should decrement messageListeners', function() { + subscription.on('message', fakeUtil.noop); + subscription.on('message', noop); + assert.strictEqual(subscription.messageListeners, 2); + + subscription.removeListener('message', noop); + assert.strictEqual(subscription.messageListeners, 1); }); - it('should unmark the message as being in progress', function() { - subscription.decorateMessage_(message).skip(); + it('should ignore non-message events', function() { + subscription.on('message', fakeUtil.noop); + subscription.on('message', noop); + assert.strictEqual(subscription.messageListeners, 2); - var inProgressAckIds = subscription.inProgressAckIds; - assert.strictEqual(inProgressAckIds[message.ackId], undefined); + subscription.removeListener('data', noop); + assert.strictEqual(subscription.messageListeners, 2); }); - it('should refresh the paused status', function(done) { - subscription.refreshPausedStatus_ = done; - subscription.decorateMessage_(message).skip(); + it('should close the connection when no listeners', function(done) { + subscription.closeConnection_ = done; + + subscription.on('message', noop); + subscription.removeListener('message', noop); }); }); }); - describe('refreshPausedStatus_', function() { - it('should pause if the ackIds in progress is too high', function() { - subscription.inProgressAckIds = { id1: true, id2: true, id3: true }; + describe('modifyPushConfig', function() { + var fakeConfig = {}; - subscription.maxInProgress = 2; - subscription.refreshPausedStatus_(); - assert.strictEqual(subscription.paused, true); - - subscription.maxInProgress = 3; - subscription.refreshPausedStatus_(); - assert.strictEqual(subscription.paused, true); + it('should make the correct request', function(done) { + subscription.request = function(config, callback) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'modifyPushConfig'); + assert.deepEqual(config.reqOpts, { + subscription: subscription.name, + pushConfig: fakeConfig + }); + callback(); // the done fn + }; - subscription.maxInProgress = Infinity; - subscription.refreshPausedStatus_(); - assert.strictEqual(subscription.paused, false); + subscription.modifyPushConfig(fakeConfig, done); }); - it('should start pulling if paused and listeners exist', function(done) { - subscription.startPulling_ = done; + it('should optionally accept gaxOpts', function(done) { + var gaxOpts = {}; + + subscription.request = function(config, callback) { + assert.strictEqual(config.gaxOpts, gaxOpts); + callback(); // the done fn + }; - subscription.inProgressAckIds = { id1: true, id2: true, id3: true }; - subscription.paused = true; - subscription.maxInProgress = Infinity; - subscription.messageListeners = 1; - subscription.refreshPausedStatus_(); + subscription.modifyPushConfig(fakeConfig, gaxOpts, done); }); }); - describe('listenForEvents_', function() { - afterEach(function() { - subscription.removeAllListeners(); + describe('nack_', function() { + var MESSAGE = { + ackId: 'abc', + connectionId: 'def' + }; + + beforeEach(function() { + subscription.setFlushTimeout_ = fakeUtil.noop; + subscription.breakLease_ = fakeUtil.noop; }); - it('should start pulling once a message listener is bound', function(done) { - subscription.startPulling_ = function() { + it('should break the lease on the message', function(done) { + subscription.breakLease_ = function(message) { + assert.strictEqual(message, MESSAGE); done(); }; - subscription.on('message', util.noop); + + subscription.nack_(MESSAGE); }); - it('should track the number of listeners', function() { - subscription.startPulling_ = util.noop; + describe('without connection pool', function() { + it('should store the ack id in the inventory object', function(done) { + subscription.setFlushTimeout_ = function() { + assert.deepEqual(subscription.inventory_.nack, [MESSAGE.ackId]); + done(); + }; - assert.strictEqual(subscription.messageListeners, 0); + subscription.nack_(MESSAGE); + }); + }); - subscription.on('message', util.noop); - assert.strictEqual(subscription.messageListeners, 1); + describe('with connection pool', function() { + var pool; - subscription.removeListener('message', util.noop); - assert.strictEqual(subscription.messageListeners, 0); - }); + beforeEach(function() { + subscription.setFlushTimeout_ = function() { + throw new Error('Should not be called.'); + }; - it('should only run a single pulling loop', function() { - var startPullingCallCount = 0; + pool = { + isConnected: function() { + return true; + } + }; - subscription.startPulling_ = function() { - startPullingCallCount++; - }; + subscription.connectionPool = pool; + }); + + it('should not write to the pool if not connected', function(done) { + subscription.setFlushTimeout_ = function() { + assert.deepEqual(subscription.inventory_.nack, [MESSAGE.ackId]); + done(); + }; + + pool.isConnected = function() { + return false; + }; + + subscription.nack_(MESSAGE); + }); + + it('should write to the connection it came in on', function(done) { + var fakeConnection = { + write: function(data) { + assert.deepEqual(data, { + modifyDeadlineAckIds: [MESSAGE.ackId], + modifyDeadlineSeconds: [0] + }); + done(); + } + }; - subscription.on('message', util.noop); - subscription.on('message', util.noop); + pool.acquire = function(connectionId, callback) { + assert.strictEqual(connectionId, MESSAGE.connectionId); + callback(null, fakeConnection); + }; + + subscription.nack_(MESSAGE); + }); + + it('should emit an error when unable to get a conn', function(done) { + var error = new Error('err'); + + pool.acquire = function(connectionId, callback) { + callback(error); + }; - assert.strictEqual(startPullingCallCount, 1); + subscription.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); + + subscription.nack_(MESSAGE); + }); }); + }); + + describe('openConnection_', function() { + it('should create a ConnectionPool instance', function() { + subscription.openConnection_(); + assert(subscription.connectionPool instanceof FakeConnectionPool); - it('should close when no more message listeners are bound', function() { - subscription.startPulling_ = util.noop; - subscription.on('message', util.noop); - subscription.on('message', util.noop); - // 2 listeners: sub should be open. - assert.strictEqual(subscription.closed, false); - subscription.removeListener('message', util.noop); - // 1 listener: sub should be open. - assert.strictEqual(subscription.closed, false); - subscription.removeListener('message', util.noop); - // 0 listeners: sub should be closed. - assert.strictEqual(subscription.closed, true); + var args = subscription.connectionPool.calledWith_; + assert.strictEqual(args[0], subscription); }); - it('should abort the HTTP request when listeners removed', function(done) { - subscription.startPulling_ = util.noop; + it('should emit pool errors', function(done) { + var error = new Error('err'); - subscription.activeRequest_ = { - abort: done - }; + subscription.on('error', function(err) { + assert.strictEqual(err, error); + done(); + }); - subscription.on('message', util.noop); - subscription.removeAllListeners(); + subscription.openConnection_(); + subscription.connectionPool.emit('error', error); }); - }); - describe('startPulling_', function() { - beforeEach(function() { - subscription.pull = util.noop; + it('should lease & emit messages from pool', function(done) { + var message = {}; + var leasedMessage = {}; + + subscription.leaseMessage_ = function(message_) { + assert.strictEqual(message_, message); + return leasedMessage; + }; + + subscription.on('message', function(message) { + assert.strictEqual(message, leasedMessage); + done(); + }); + + subscription.openConnection_(); + subscription.connectionPool.emit('message', message); }); - it('should not pull if subscription is closed', function() { - subscription.pull = function() { - throw new Error('Should not be called.'); + it('should pause the pool if sub is at max messages', function(done) { + var message = {}; + var leasedMessage = {}; + + subscription.leaseMessage_ = function() { + return leasedMessage; }; - subscription.closed = true; - subscription.startPulling_(); + subscription.hasMaxMessages_ = function() { + return true; + }; + + subscription.openConnection_(); + subscription.connectionPool.isPaused = false; + subscription.connectionPool.pause = done; + subscription.connectionPool.emit('message', message); }); - it('should not pull if subscription is paused', function() { - subscription.pull = function() { - throw new Error('Should not be called.'); + it('should not re-pause the pool', function(done) { + var message = {}; + var leasedMessage = {}; + + subscription.leaseMessage_ = function() { + return leasedMessage; + }; + + subscription.hasMaxMessages_ = function() { + return true; + }; + + subscription.openConnection_(); + subscription.connectionPool.isPaused = true; + + subscription.connectionPool.pause = function() { + done(new Error('Should not have been called.')); }; - subscription.paused = true; - subscription.startPulling_(); + subscription.connectionPool.emit('message', message); + done(); }); - it('should set returnImmediately to false when pulling', function(done) { - subscription.pull = function(options) { - assert.strictEqual(options.returnImmediately, false); + it('should flush the queue when connected', function(done) { + subscription.flushQueues_ = function() { + assert.strictEqual(subscription.flushTimeoutHandle_, null); done(); }; - subscription.closed = false; - subscription.startPulling_(); + subscription.flushTimeoutHandle_ = setTimeout(done, 1); + subscription.openConnection_(); + subscription.connectionPool.emit('connected'); }); + }); - it('should not set maxResults if no maxInProgress is set', function(done) { - subscription.pull = function(options) { - assert.strictEqual(options.maxResults, undefined); - done(); + describe('renewLeases_', function() { + var fakeDeadline = 9999; + + beforeEach(function() { + subscription.inventory_.lease = ['abc', 'def']; + subscription.setLeaseTimeout_ = fakeUtil.noop; + + subscription.histogram.percentile = function() { + return fakeDeadline; + }; + }); + + it('should update the ackDeadline', function() { + subscription.request = subscription.setLeaseTimeout_ = fakeUtil.noop; + + subscription.histogram.percentile = function(percent) { + assert.strictEqual(percent, 99); + return fakeDeadline; }; - subscription.closed = false; - subscription.startPulling_(); + subscription.renewLeases_(); + assert.strictEqual(subscription.ackDeadline, fakeDeadline); }); - it('should set maxResults properly with maxInProgress', function(done) { - subscription.pull = function(options) { - assert.strictEqual(options.maxResults, 1); - done(); + it('should set the auto-lease timeout', function(done) { + subscription.request = fakeUtil.noop; + subscription.setLeaseTimeout_ = done; + subscription.renewLeases_(); + }); + + it('should not renew leases if inventory is empty', function() { + subscription.connectionPool = { + acquire: function() { + throw new Error('Should not have been called.'); + } }; - subscription.closed = false; - subscription.maxInProgress = 4; - subscription.inProgressAckIds = { id1: true, id2: true, id3: true }; - subscription.startPulling_(); + subscription.inventory_.lease = []; + subscription.renewLeases_(); }); - it('should emit an error event if one is encountered', function(done) { - var error = new Error('Error.'); - subscription.pull = function(options, callback) { - subscription.pull = function() {}; - setImmediate(function() { + describe('with connection pool', function() { + var fakeConnection; + + beforeEach(function() { + fakeConnection = { + acquire: fakeUtil.noop + }; + + subscription.connectionPool = { + acquire: function(callback) { + callback(null, fakeConnection); + } + }; + }); + + it('should emit any pool acquiring errors', function(done) { + var error = new Error('err'); + + subscription.connectionPool.acquire = function(callback) { callback(error); + }; + + subscription.on('error', function(err) { + assert.strictEqual(err, error); + done(); }); - }; - subscription.closed = false; - subscription - .once('error', function(err) { - assert.equal(err, error); + subscription.renewLeases_(); + }); + + it('should write to the connection', function(done) { + fakeConnection.write = function(reqOpts) { + assert.deepEqual(reqOpts, { + modifyDeadlineAckIds: ['abc', 'def'], + modifyDeadlineSeconds: Array(2).fill(fakeDeadline / 1000) + }); done(); - }) - .startPulling_(); + }; + + subscription.renewLeases_(); + }); }); - it('should emit an error event with apiResponse', function(done) { - var error = new Error('Error.'); - var resp = { success: false }; - subscription.pull = function(options, callback) { - subscription.pull = function() {}; - setImmediate(function() { - callback(error, null, resp); + describe('without connection pool', function() { + it('should make the correct request', function(done) { + subscription.request = function(config, callback) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'modifyAckDeadline'); + assert.deepEqual(config.reqOpts, { + subscription: subscription.name, + ackIds: ['abc', 'def'], + ackDeadlineSeconds: fakeDeadline / 1000 + }); + callback(); + done(); + }; + + subscription.on('error', function(err) { + done(err); }); - }; - subscription.closed = false; - subscription - .once('error', function(err, apiResponse) { - assert.equal(err, error); - assert.deepEqual(resp, apiResponse); + subscription.renewLeases_(); + }); + + it('should emit any request errors', function(done) { + var error = new Error('err'); + + subscription.request = function(config, callback) { + callback(error); + }; + + subscription.on('error', function(err) { + assert.strictEqual(err, error); done(); - }) - .startPulling_(); + }); + + subscription.renewLeases_(); + }); }); + }); + + describe('seek', function() { + var FAKE_SNAPSHOT_NAME = 'a'; + var FAKE_FULL_SNAPSHOT_NAME = 'a/b/c/d'; - it('should emit a message event', function(done) { - subscription.pull = function(options, callback) { - callback(null, [{ hi: 'there' }]); + beforeEach(function() { + FakeSnapshot.formatName_ = function() { + return FAKE_FULL_SNAPSHOT_NAME; }; + }); - subscription - .once('message', function(msg) { - assert.deepEqual(msg, { hi: 'there' }); - done(); + it('should throw if a name or date is not provided', function() { + assert.throws(function() { + subscription.seek(); + }, /Either a snapshot name or Date is needed to seek to\./); + }); + + it('should make the correct api request', function(done) { + FakeSnapshot.formatName_ = function(projectId, name) { + assert.strictEqual(projectId, PROJECT_ID); + assert.strictEqual(name, FAKE_SNAPSHOT_NAME); + return FAKE_FULL_SNAPSHOT_NAME; + }; + + subscription.request = function(config, callback) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'seek'); + assert.deepEqual(config.reqOpts, { + subscription: subscription.name, + snapshot: FAKE_FULL_SNAPSHOT_NAME }); + callback(); // the done fn + }; + + subscription.seek(FAKE_SNAPSHOT_NAME, done); }); - it('should emit a message event with apiResponse', function(done) { - var resp = { success: true, msgs: [{ hi: 'there' }] }; - subscription.pull = function(options, callback) { - callback(null, [{ hi: 'there' }], resp); + it('should optionally accept a Date object', function(done) { + var date = new Date(); + + subscription.request = function(config, callback) { + assert.strictEqual(config.reqOpts.time, date); + callback(); // the done fn }; - subscription - .once('message', function(msg, apiResponse) { - assert.deepEqual(resp, apiResponse); - done(); + + subscription.seek(date, done); + }); + + it('should optionally accept gax options', function(done) { + var gaxOpts = {}; + + subscription.request = function(config, callback) { + assert.strictEqual(config.gaxOpts, gaxOpts); + callback(); // the done fn + }; + + subscription.seek(FAKE_SNAPSHOT_NAME, gaxOpts, done); + }); + }); + + describe('setFlushTimeout_', function() { + var fakeTimeoutHandle = 1234; + var globalSetTimeout; + + before(function() { + globalSetTimeout = global.setTimeout; + }); + + after(function() { + global.setTimeout = globalSetTimeout; + }); + + it('should set a timeout to call flushQueues', function(done) { + subscription.flushQueues_ = done; + + global.setTimeout = function(callback, duration) { + assert.strictEqual(duration, 1000); + setImmediate(callback); // the done fn + return fakeTimeoutHandle; + }; + + subscription.setFlushTimeout_(); + assert.strictEqual(subscription.flushTimeoutHandle_, fakeTimeoutHandle); + }); + + it('should not set a timeout if one already exists', function() { + subscription.flushQueues_ = function() { + throw new Error('Should not be called.'); + }; + + global.setTimeout = function() { + throw new Error('Should not be called.'); + }; + + subscription.flushTimeoutHandle_ = fakeTimeoutHandle; + subscription.setFlushTimeout_(); + }); + }); + + describe('setLeaseTimeout_', function() { + var fakeTimeoutHandle = 1234; + var fakeRandom = 2; + + var globalSetTimeout; + var globalMathRandom; + + before(function() { + globalSetTimeout = global.setTimeout; + globalMathRandom = global.Math.random; + }); + + after(function() { + global.setTimeout = globalSetTimeout; + global.Math.random = globalMathRandom; + }); + + it('should set a timeout to call renewLeases_', function(done) { + var ackDeadline = subscription.ackDeadline = 1000; + + global.Math.random = function() { + return fakeRandom; + }; + + global.setTimeout = function(callback, duration) { + assert.strictEqual(duration, fakeRandom * ackDeadline * 0.9); + setImmediate(callback); // the done fn + return fakeTimeoutHandle; + }; + + subscription.renewLeases_ = done; + subscription.setLeaseTimeout_(); + assert.strictEqual(subscription.leaseTimeoutHandle_, fakeTimeoutHandle); + }); + + it('should not set a timeout if one already exists', function() { + subscription.renewLeases_ = function() { + throw new Error('Should not be called.'); + }; + + global.Math.random = function() { + throw new Error('Should not be called.'); + }; + + global.setTimeout = function() { + throw new Error('Should not be called.'); + }; + + subscription.leaseTimeoutHandle_ = fakeTimeoutHandle; + subscription.setLeaseTimeout_(); + }); + }); + + describe('setMetadata', function() { + var METADATA = {}; + + it('should make the correct request', function(done) { + subscription.request = function(config, callback) { + assert.strictEqual(config.client, 'subscriberClient'); + assert.strictEqual(config.method, 'updateSubscription'); + assert.deepEqual(config.reqOpts, { + subscription: subscription.name, + updateMask: METADATA }); + callback(); // the done fn + }; + + subscription.setMetadata(METADATA, done); }); - it('should pull at specified interval', function(done) { - var INTERVAL = 5; - subscription.pull = function(options, callback) { - assert.strictEqual(options.returnImmediately, false); - // After pull is called once, overwrite with `done`. - // This is to override the function passed to `setTimeout`, so we are - // sure it's the same pull function when we execute it. - subscription.pull = function() { - done(); - }; - callback(); + it('should optionally accept gax options', function(done) { + var gaxOpts = {}; + + subscription.request = function(config, callback) { + assert.strictEqual(config.gaxOpts, gaxOpts); + callback(); // the done fn }; - var setTimeout = global.setTimeout; - global.setTimeout = function(fn, interval) { - global.setTimeout = setTimeout; - assert.equal(interval, INTERVAL); - // This should execute the `done` function from when we overrided it - // above. - fn(); + + subscription.setMetadata(METADATA, gaxOpts, done); + }); + }); + + describe('snapshot', function() { + var SNAPSHOT_NAME = 'a'; + + it('should call through to pubsub.snapshot', function(done) { + PUBSUB.snapshot = function(name) { + assert.strictEqual(this, subscription); + assert.strictEqual(name, SNAPSHOT_NAME); + done(); }; - subscription.closed = false; - subscription.interval = INTERVAL; - subscription.startPulling_(); + subscription.snapshot(SNAPSHOT_NAME); }); }); }); diff --git a/packages/pubsub/test/topic.js b/packages/pubsub/test/topic.js index 48985c6353a..ef73a6464ff 100644 --- a/packages/pubsub/test/topic.js +++ b/packages/pubsub/test/topic.js @@ -18,8 +18,6 @@ var assert = require('assert'); var extend = require('extend'); -var GrpcServiceObject = require('@google-cloud/common-grpc').ServiceObject; -var nodeutil = require('util'); var proxyquire = require('proxyquire'); var util = require('@google-cloud/common').util; @@ -31,21 +29,36 @@ var fakeUtil = extend({}, util, { } promisified = true; - assert.deepEqual(options.exclude, ['subscription']); + assert.deepEqual(options.exclude, [ + 'publisher', + 'subscription' + ]); } }); -function FakeGrpcServiceObject() { - this.calledWith_ = arguments; - GrpcServiceObject.apply(this, arguments); +function FakeIAM() { + this.calledWith_ = [].slice.call(arguments); } -nodeutil.inherits(FakeGrpcServiceObject, GrpcServiceObject); - -function FakeIAM() { +function FakePublisher() { this.calledWith_ = [].slice.call(arguments); } +var extended = false; +var fakePaginator = { + extend: function(Class, methods) { + if (Class.name !== 'Topic') { + return; + } + + assert.deepEqual(methods, ['getSubscriptions']); + extended = true; + }, + streamify: function(methodName) { + return methodName; + } +}; + describe('Topic', function() { var Topic; var topic; @@ -53,20 +66,21 @@ describe('Topic', function() { var PROJECT_ID = 'test-project'; var TOPIC_NAME = 'projects/' + PROJECT_ID + '/topics/test-topic'; var TOPIC_UNFORMATTED_NAME = TOPIC_NAME.split('/').pop(); + var PUBSUB = { projectId: PROJECT_ID, - createTopic: util.noop + createTopic: util.noop, + request: util.noop }; before(function() { Topic = proxyquire('../src/topic.js', { '@google-cloud/common': { + paginator: fakePaginator, util: fakeUtil }, - '@google-cloud/common-grpc': { - ServiceObject: FakeGrpcServiceObject - }, - './iam.js': FakeIAM + './iam.js': FakeIAM, + './publisher.js': FakePublisher }); }); @@ -76,80 +90,50 @@ describe('Topic', function() { }); describe('initialization', function() { - it('should inherit from GrpcServiceObject', function() { - var pubsubInstance = extend({}, PUBSUB, { - createTopic: { - bind: function(context) { - assert.strictEqual(context, pubsubInstance); - } - } - }); + it('should extend the correct methods', function() { + assert(extended); // See `fakePaginator.extend` + }); - var topic = new Topic(pubsubInstance, TOPIC_NAME); - assert(topic instanceof GrpcServiceObject); - - var calledWith = topic.calledWith_[0]; - - assert.strictEqual(calledWith.parent, pubsubInstance); - assert.strictEqual(calledWith.id, TOPIC_NAME); - assert.deepEqual(calledWith.methods, { - create: true, - delete: { - protoOpts: { - service: 'Publisher', - method: 'deleteTopic' - }, - reqOpts: { - topic: TOPIC_NAME - } - }, - exists: true, - get: true, - getMetadata: { - protoOpts: { - service: 'Publisher', - method: 'getTopic' - }, - reqOpts: { - topic: TOPIC_NAME - } - } - }); + it('should streamify the correct methods', function() { + assert.strictEqual(topic.getSubscriptionsStream, 'getSubscriptions'); }); - it('should create an iam object', function() { - assert.deepEqual(topic.iam.calledWith_, [PUBSUB, TOPIC_NAME]); + it('should promisify all the things', function() { + assert(promisified); }); - it('should format name', function(done) { + it('should format the name', function() { + var formattedName = 'a/b/c/d'; + var formatName_ = Topic.formatName_; - Topic.formatName_ = function() { + Topic.formatName_ = function(projectId, name) { + assert.strictEqual(projectId, PROJECT_ID); + assert.strictEqual(name, TOPIC_NAME); + Topic.formatName_ = formatName_; - done(); + + return formattedName; }; - new Topic(PUBSUB, TOPIC_NAME); + + var topic = new Topic(PUBSUB, TOPIC_NAME); + assert.strictEqual(topic.name, formattedName); }); - }); - describe('formatMessage_', function() { - var messageString = 'string'; - var messageBuffer = new Buffer(messageString); + it('should localize the parent object', function() { + assert.strictEqual(topic.pubsub, PUBSUB); + }); - var messageObjectWithString = { data: messageString }; - var messageObjectWithBuffer = { data: messageBuffer }; + it('should localize the request function', function(done) { + PUBSUB.request = function(callback) { + callback(); // the done fn + }; - it('should handle string data', function() { - assert.deepEqual( - Topic.formatMessage_(messageObjectWithString), - { data: new Buffer(JSON.stringify(messageString)).toString('base64') } - ); + var topic = new Topic(PUBSUB, TOPIC_NAME); + topic.request(done); }); - it('should handle buffer data', function() { - assert.deepEqual( - Topic.formatMessage_(messageObjectWithBuffer), - { data: messageBuffer.toString('base64') } - ); + it('should create an iam object', function() { + assert.deepEqual(topic.iam.calledWith_, [PUBSUB, TOPIC_NAME]); }); }); @@ -165,189 +149,362 @@ describe('Topic', function() { }); }); - describe('getSubscriptions', function() { - it('should accept just a callback', function(done) { - topic.parent.getSubscriptions = function(options, callback) { - assert.deepEqual(options, { topic: topic }); - callback(); + describe('create', function() { + it('should call the parent createTopic method', function(done) { + var options_ = {}; + + PUBSUB.createTopic = function(name, options, callback) { + assert.strictEqual(name, topic.name); + assert.strictEqual(options, options_); + callback(); // the done fn }; - topic.getSubscriptions(done); + topic.create(options_, done); }); + }); - it('should pass correct args to pubsub#getSubscriptions', function(done) { - var opts = { a: 'b', c: 'd' }; + describe('createSubscription', function() { + it('should call the parent createSubscription method', function(done) { + var NAME = 'sub-name'; + var OPTIONS = { a: 'a' }; - topic.parent = { - getSubscriptions: function(options, callback) { - assert.deepEqual(options, opts); - assert.deepEqual(options.topic, topic); - callback(); - } + PUBSUB.createSubscription = function(topic_, name, options, callback) { + assert.strictEqual(topic_, topic); + assert.strictEqual(name, NAME); + assert.strictEqual(options, OPTIONS); + callback(); // the done fn }; - topic.getSubscriptions(opts, done); + topic.createSubscription(NAME, OPTIONS, done); }); }); - describe('getSubscriptionsStream', function() { - it('should return a stream', function(done) { - var fakeStream = {}; + describe('delete', function() { + it('should make the proper request', function(done) { + topic.request = function(config, callback) { + assert.strictEqual(config.client, 'publisherClient'); + assert.strictEqual(config.method, 'deleteTopic'); + assert.deepEqual(config.reqOpts, { topic: topic.name }); + callback(); // the done fn + }; + + topic.delete(done); + }); + + it('should optionally accept gax options', function(done) { + var options = {}; - topic.parent.getSubscriptionsStream = function(options) { - assert.deepEqual(options, { topic: topic }); - setImmediate(done); - return fakeStream; + topic.request = function(config, callback) { + assert.strictEqual(config.gaxOpts, options); + callback(); // the done fn }; - var stream = topic.getSubscriptionsStream(); - assert.strictEqual(stream, fakeStream); + topic.delete(options, done); }); - it('should pass correct args to getSubscriptionsStream', function(done) { - var opts = { a: 'b', c: 'd' }; + it('should optionally accept a callback', function(done) { + fakeUtil.noop = done; - topic.parent = { - getSubscriptionsStream: function(options) { - assert.deepEqual(options, opts); - assert.deepEqual(options.topic, topic); - done(); - } + topic.request = function(config, callback) { + callback(); // the done fn }; - topic.getSubscriptionsStream(opts); + topic.delete(); }); }); - describe('publish', function() { - var message = 'howdy'; - var attributes = { - key: 'value' - }; + describe('get', function() { + it('should delete the autoCreate option', function(done) { + var options = { + autoCreate: true, + a: 'a' + }; - it('should throw if no message is provided', function() { - assert.throws(function() { - topic.publish(); - }, /Cannot publish without a message\./); + topic.getMetadata = function(gaxOpts) { + assert.strictEqual(gaxOpts, options); + assert.strictEqual(gaxOpts.autoCreate, undefined); + done(); + }; - assert.throws(function() { - topic.publish([]); - }, /Cannot publish without a message\./); + topic.get(options, assert.ifError); }); - it('should send correct api request', function(done) { - topic.parent.request = function(protoOpts, reqOpts) { - assert.strictEqual(protoOpts.service, 'Publisher'); - assert.strictEqual(protoOpts.method, 'publish'); + describe('success', function() { + var fakeMetadata = {}; - assert.strictEqual(reqOpts.topic, topic.name); - assert.deepEqual(reqOpts.messages, [ - { data: new Buffer(JSON.stringify(message)).toString('base64') } - ]); + beforeEach(function() { + topic.getMetadata = function(gaxOpts, callback) { + callback(null, fakeMetadata); + }; + }); + + it('should call through to getMetadata', function(done) { + topic.get(function(err, _topic, resp) { + assert.ifError(err); + assert.strictEqual(_topic, topic); + assert.strictEqual(resp, fakeMetadata); + done(); + }); + }); + + it('should optionally accept options', function(done) { + var options = {}; + + topic.getMetadata = function(gaxOpts, callback) { + assert.strictEqual(gaxOpts, options); + callback(); // the done fn + }; + + topic.get(options, done); + }); + }); + + describe('error', function() { + it('should pass back errors when not auto-creating', function(done) { + var error = { code: 4 }; + var apiResponse = {}; + + topic.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); + }; + + topic.get(function(err, _topic, resp) { + assert.strictEqual(err, error); + assert.strictEqual(_topic, null); + assert.strictEqual(resp, apiResponse); + done(); + }); + }); + + it('should pass back 404 errors if autoCreate is false', function(done) { + var error = { code: 5 }; + var apiResponse = {}; + + topic.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); + }; + + topic.get(function(err, _topic, resp) { + assert.strictEqual(err, error); + assert.strictEqual(_topic, null); + assert.strictEqual(resp, apiResponse); + done(); + }); + }); + + it('should create the topic if 404 + autoCreate is true', function(done) { + var error = { code: 5 }; + var apiResponse = {}; + var fakeOptions = { + autoCreate: true + }; + + topic.getMetadata = function(gaxOpts, callback) { + callback(error, apiResponse); + }; + + topic.create = function(options, callback) { + assert.strictEqual(options, fakeOptions); + callback(); // the done fn + }; + + topic.get(fakeOptions, done); + }); + }); + }); + + describe('exists', function() { + it('should return true if it finds metadata', function(done) { + topic.getMetadata = function(callback) { + callback(null, {}); + }; + + topic.exists(function(err, exists) { + assert.ifError(err); + assert(exists); + done(); + }); + }); + + it('should return false if a not found error occurs', function(done) { + topic.getMetadata = function(callback) { + callback({ code: 5 }); + }; + + topic.exists(function(err, exists) { + assert.ifError(err); + assert.strictEqual(exists, false); done(); + }); + }); + + it('should pass back any other type of error', function(done) { + var error = { code: 4 }; + + topic.getMetadata = function(callback) { + callback(error); }; - topic.publish(message, assert.ifError); + topic.exists(function(err, exists) { + assert.strictEqual(err, error); + assert.strictEqual(exists, undefined); + done(); + }); }); + }); - it('should honor the timeout setting', function(done) { - var options = { - timeout: 10 + describe('getMetadata', function() { + it('should make the proper request', function(done) { + topic.request = function(config) { + assert.strictEqual(config.client, 'publisherClient'); + assert.strictEqual(config.method, 'getTopic'); + assert.deepEqual(config.reqOpts, { topic: topic.name }); + done(); }; - topic.parent.request = function(protoOpts) { - assert.strictEqual(protoOpts.timeout, options.timeout); + topic.getMetadata(assert.ifError); + }); + + it('should optionally accept gax options', function(done) { + var options = {}; + + topic.request = function(config) { + assert.strictEqual(config.gaxOpts, options); done(); }; - topic.publish(message, options, assert.ifError); + topic.getMetadata(options, assert.ifError); }); - it('should send correct api request for raw message', function(done) { - topic.parent.request = function(protoOpts, reqOpts) { - assert.deepEqual(reqOpts.messages, [ - { - data: new Buffer(JSON.stringify(message)).toString('base64'), - attributes: attributes - } - ]); + it('should pass back any errors that occur', function(done) { + var error = new Error('err'); + var apiResponse = {}; + + topic.request = function(config, callback) { + callback(error, apiResponse); + }; + topic.getMetadata(function(err, metadata) { + assert.strictEqual(err, error); + assert.strictEqual(metadata, apiResponse); done(); + }); + }); + + it('should set the metadata if no error occurs', function(done) { + var apiResponse = {}; + + topic.request = function(config, callback) { + callback(null, apiResponse); }; - topic.publish({ - data: message, - attributes: attributes - }, { raw: true }, assert.ifError); + topic.getMetadata(function(err, metadata) { + assert.ifError(err); + assert.strictEqual(metadata, apiResponse); + assert.strictEqual(topic.metadata, apiResponse); + done(); + }); }); + }); - it('should clone the provided message', function(done) { - var message = { - data: 'data' + describe('getSubscriptions', function() { + it('should make the correct request', function(done) { + var options = { + a: 'a', + b: 'b', + gaxOpts: { + e: 'f' + }, + autoPaginate: false }; - var originalMessage = extend({}, message); - topic.parent.request = function() { - assert.deepEqual(message, originalMessage); + var expectedOptions = extend({ + topic: topic.name + }, options); + + var expectedGaxOpts = extend({ + autoPaginate: options.autoPaginate + }, options.gaxOpts); + + delete expectedOptions.gaxOpts; + delete expectedOptions.autoPaginate; + + topic.request = function(config) { + assert.strictEqual(config.client, 'publisherClient'); + assert.strictEqual(config.method, 'listTopicSubscriptions'); + assert.deepEqual(config.reqOpts, expectedOptions); + assert.deepEqual(config.gaxOpts, expectedGaxOpts); done(); }; - topic.publish(message, { raw: true }, assert.ifError); + topic.getSubscriptions(options, assert.ifError); }); - it('should execute callback', function(done) { - topic.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, {}); + it('should accept only a callback', function(done) { + topic.request = function(config) { + assert.deepEqual(config.reqOpts, { topic: topic.name }); + assert.deepEqual(config.gaxOpts, { autoPaginate: undefined }); + done(); }; - topic.publish(message, done); + topic.getSubscriptions(assert.ifError); }); - it('should execute callback with error', function(done) { - var error = new Error('Error.'); - var apiResponse = {}; + it('should create subscription objects', function(done) { + var fakeSubs = ['a', 'b', 'c']; - topic.parent.request = function(protoOpts, reqOpts, callback) { - callback(error, apiResponse); + topic.subscription = function(name) { + return { + name: name + }; }; - topic.publish(message, function(err, ackIds, apiResponse_) { - assert.strictEqual(err, error); - assert.strictEqual(ackIds, null); - assert.strictEqual(apiResponse_, apiResponse); + topic.request = function(config, callback) { + callback(null, fakeSubs); + }; + topic.getSubscriptions(function(err, subscriptions) { + assert.ifError(err); + assert.deepEqual(subscriptions, [ + { name: 'a' }, + { name: 'b' }, + { name: 'c' } + ]); done(); }); }); - it('should execute callback with apiResponse', function(done) { - var resp = { success: true }; + it('should pass all params to the callback', function(done) { + var err_ = new Error('err'); + var subs_ = false; + var nextQuery_ = {}; + var apiResponse_ = {}; - topic.parent.request = function(protoOpts, reqOpts, callback) { - callback(null, resp); + topic.request = function(config, callback) { + callback(err_, subs_, nextQuery_, apiResponse_); }; - topic.publish(message, function(err, ackIds, apiResponse) { - assert.deepEqual(resp, apiResponse); + topic.getSubscriptions(function(err, subs, nextQuery, apiResponse) { + assert.strictEqual(err, err_); + assert.deepEqual(subs, subs_); + assert.strictEqual(nextQuery, nextQuery_); + assert.strictEqual(apiResponse, apiResponse_); done(); }); }); }); - describe('subscribe', function() { - it('should pass correct arguments to pubsub#subscribe', function(done) { - var subscriptionName = 'subName'; - var opts = {}; + describe('publisher', function() { + it('should return a Publisher instance', function() { + var options = {}; - topic.parent.subscribe = function(t, subName, options, callback) { - assert.deepEqual(t, topic); - assert.equal(subName, subscriptionName); - assert.deepEqual(options, opts); - callback(); - }; + var publisher = topic.publisher(options); + var args = publisher.calledWith_; - topic.subscribe(subscriptionName, opts, done); + assert(publisher instanceof FakePublisher); + assert.strictEqual(args[0], topic); + assert.strictEqual(args[1], options); }); }); diff --git a/scripts/docs/config.js b/scripts/docs/config.js index fbb4974401e..a840daeb23f 100644 --- a/scripts/docs/config.js +++ b/scripts/docs/config.js @@ -32,6 +32,8 @@ module.exports = { 'error-reporting/src/error-router.js', 'error-reporting/src/logger.js', 'logging/src/metadata.js', + 'pubsub/src/connection-pool.js', + 'pubsub/src/histogram.js', 'pubsub/src/iam.js', 'spanner/src/codec.js', 'spanner/src/partial-result-stream.js',