From 9474e3f58907f4d132b53b5726f0784ef9ab0b7a Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Wed, 15 May 2019 21:06:15 -0700 Subject: [PATCH] fix: Support more than 100 long-lived streams (#623) --- dev/src/index.ts | 109 ++++++++++++++++++++--------------- dev/src/pool.ts | 33 +++++------ dev/src/util.ts | 19 ++++++ dev/system-test/firestore.ts | 68 +++++++++++++++++++--- dev/test/collection.ts | 3 + dev/test/document.ts | 13 +++++ dev/test/index.ts | 7 +++ dev/test/order.ts | 4 +- dev/test/pool.ts | 2 +- dev/test/query.ts | 21 +++++++ dev/test/util/helpers.ts | 34 +++++------ dev/test/watch.ts | 32 +++++----- dev/test/write-batch.ts | 17 +++++- 13 files changed, 250 insertions(+), 112 deletions(-) diff --git a/dev/src/index.ts b/dev/src/index.ts index 545824901..bf331c953 100644 --- a/dev/src/index.ts +++ b/dev/src/index.ts @@ -46,7 +46,7 @@ import { ReadOptions, Settings, } from './types'; -import {requestTag} from './util'; +import {Deferred, requestTag} from './util'; import { validateBoolean, validateFunction, @@ -1131,22 +1131,19 @@ export class Firestore { * @returns The given Stream once it is considered healthy. */ private _initializeStream( - releaser: () => void, resultStream: NodeJS.ReadableStream, requestTag: string - ): Promise; + ): Promise; private _initializeStream( - releaser: () => void, resultStream: NodeJS.ReadWriteStream, requestTag: string, request: {} - ): Promise; + ): Promise; private _initializeStream( - releaser: () => void, resultStream: NodeJS.ReadableStream | NodeJS.ReadWriteStream, requestTag: string, request?: {} - ): Promise { + ): Promise { /** The last error we received and have not forwarded yet. */ let errorReceived: Error | null = null; @@ -1172,7 +1169,6 @@ export class Firestore { errorReceived ); resultStream.emit('error', errorReceived); - releaser(); errorReceived = null; } else if (!streamInitialized) { logger('Firestore._initializeStream', requestTag, 'Releasing stream'); @@ -1183,7 +1179,7 @@ export class Firestore { // 'end' event we intend to forward here. We therefore need to wait // until the API consumer registers their listeners (in the .then() // call) before emitting any further events. - resolve(resultStream); + resolve(); // We execute the forwarding of the 'end' event via setTimeout() as // V8 guarantees that the above the Promise chain is resolved before @@ -1196,7 +1192,6 @@ export class Firestore { 'Forwarding stream close' ); resultStream.emit('end'); - releaser(); } }, 0); } @@ -1238,7 +1233,6 @@ export class Firestore { ); streamInitialized = true; reject(err); - releaser(); } else { errorReceived = err; } @@ -1346,36 +1340,49 @@ export class Firestore { const attempts = allowRetries ? MAX_REQUEST_RETRIES : 1; const callOptions = this.createCallOptions(); - const gapicClient = this._clientPool.acquire(); - const releaser = this._clientPool.createReleaser(gapicClient); + const result = new Deferred(); - return this._retry(attempts, requestTag, () => { - return new Promise((resolve, reject) => { - try { + this._clientPool.run(gapicClient => { + // While we return the stream to the callee early, we don't want to + // release the GAPIC client until the callee has finished processing the + // stream. + const lifetime = new Deferred(); + + this._retry(attempts, requestTag, async () => { + logger( + 'Firestore.readStream', + requestTag, + 'Sending request: %j', + request + ); + const stream = gapicClient[methodName](request, callOptions); + const logStream = through2.obj(function(this, chunk, enc, callback) { logger( 'Firestore.readStream', requestTag, - 'Sending request: %j', - request + 'Received response: %j', + chunk ); - const stream = gapicClient[methodName](request, callOptions); - const logStream = through2.obj(function(this, chunk, enc, callback) { - logger( - 'Firestore.readStream', - requestTag, - 'Received response: %j', - chunk - ); - this.push(chunk); - callback(); - }); - resolve(bun([stream, logStream])); - } catch (err) { - logger('Firestore.readStream', requestTag, 'Received error:', err); - reject(err); - } - }).then(stream => this._initializeStream(releaser, stream, requestTag)); + this.push(chunk); + callback(); + }); + + const resultStream = bun([stream, logStream]); + resultStream.on('close', lifetime.resolve); + resultStream.on('end', lifetime.resolve); + resultStream.on('error', lifetime.resolve); + + await this._initializeStream(resultStream, requestTag); + result.resolve(resultStream); + }).catch(err => { + lifetime.resolve(); + result.reject(err); + }); + + return lifetime.promise; }); + + return result.promise; } /** @@ -1403,11 +1410,15 @@ export class Firestore { const attempts = allowRetries ? MAX_REQUEST_RETRIES : 1; const callOptions = this.createCallOptions(); - const gapicClient = this._clientPool.acquire(); - const releaser = this._clientPool.createReleaser(gapicClient); + const result = new Deferred(); + + this._clientPool.run(gapicClient => { + // While we return the stream to the callee early, we don't want to + // release the GAPIC client until the callee has finished processing the + // stream. + const lifetime = new Deferred(); - return this._retry(attempts, requestTag, () => { - return Promise.resolve().then(() => { + this._retry(attempts, requestTag, async () => { logger('Firestore.readWriteStream', requestTag, 'Opening stream'); const requestStream = gapicClient[methodName](callOptions); @@ -1423,14 +1434,22 @@ export class Firestore { }); const resultStream = bun([requestStream, logStream]); - return this._initializeStream( - releaser, - resultStream, - requestTag, - request - ); + resultStream.on('close', lifetime.resolve); + resultStream.on('finish', lifetime.resolve); + resultStream.on('end', lifetime.resolve); + resultStream.on('error', lifetime.resolve); + + await this._initializeStream(resultStream, requestTag, request); + result.resolve(resultStream); + }).catch(err => { + lifetime.resolve(); + result.reject(err); }); + + return lifetime.promise; }); + + return result.promise; } } diff --git a/dev/src/pool.ts b/dev/src/pool.ts index fd1114739..4e28e5ba2 100644 --- a/dev/src/pool.ts +++ b/dev/src/pool.ts @@ -50,7 +50,7 @@ export class ClientPool { * * @private */ - acquire(): T { + private acquire(): T { let selectedClient: T | null = null; let selectedRequestCount = 0; @@ -79,7 +79,7 @@ export class ClientPool { * removing it from the pool of active clients. * @private */ - release(client: T): void { + private release(client: T): void { let requestCount = this.activeClients.get(client) || 0; assert(requestCount > 0, 'No active request'); @@ -92,34 +92,27 @@ export class ClientPool { } /** - * Creates a new function that will release the given client, when called. - * - * This guarantees that the given client can only be released once. + * The number of currently registered clients. * + * @return Number of currently registered clients. * @private */ - createReleaser(client: T): () => void { - // Unfortunately, once the release() call is disconnected from the Promise - // returned from _initializeStream, there's no single callback in which the - // releaser can be guaranteed to be called once. - let released = false; - return () => { - if (!released) { - released = true; - this.release(client); - } - }; + // Visible for testing. + get size(): number { + return this.activeClients.size; } /** - * The number of currently registered clients. + * The number of currently active operations. * - * @return Number of currently registered clients. + * @return Number of currently active operations. * @private */ // Visible for testing. - get size(): number { - return this.activeClients.size; + get opCount(): number { + let activeOperationCount = 0; + this.activeClients.forEach(count => (activeOperationCount += count)); + return activeOperationCount; } /** diff --git a/dev/src/util.ts b/dev/src/util.ts index 9ebc41592..a81ec8e66 100644 --- a/dev/src/util.ts +++ b/dev/src/util.ts @@ -14,6 +14,25 @@ * limitations under the License. */ +/** A Promise implementation that supports deferred resolution. */ +export class Deferred { + promise: Promise; + resolve: (value?: R | Promise) => void = () => {}; + reject: (reason?: Error) => void = () => {}; + + constructor() { + this.promise = new Promise( + ( + resolve: (value?: R | Promise) => void, + reject: (reason?: Error) => void + ) => { + this.resolve = resolve; + this.reject = reject; + } + ); + } +} + /** * Generate a unique client-side identifier. * diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index 496acb190..c6a01d91c 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -30,7 +30,8 @@ import { setLogFunction, Timestamp, } from '../src'; -import {autoId} from '../src/util'; +import {autoId, Deferred} from '../src/util'; +import {verifyInstance} from '../test/util/helpers'; const version = require('../../package.json').version; @@ -67,6 +68,8 @@ describe('Firestore class', () => { randomCol = getTestRoot(firestore); }); + afterEach(() => verifyInstance(firestore)); + it('has collection() method', () => { const ref = firestore.collection('col'); expect(ref.id).to.equal('col'); @@ -136,6 +139,8 @@ describe('CollectionReference class', () => { randomCol = getTestRoot(firestore); }); + afterEach(() => verifyInstance(firestore)); + it('has firestore property', () => { const ref = firestore.collection('col'); expect(ref.firestore).to.be.an.instanceOf(Firestore); @@ -202,6 +207,8 @@ describe('DocumentReference class', () => { randomCol = getTestRoot(firestore); }); + afterEach(() => verifyInstance(firestore)); + it('has firestore property', () => { const ref = firestore.doc('col/doc'); expect(ref.firestore).to.be.an.instanceOf(Firestore); @@ -839,20 +846,22 @@ describe('DocumentReference class', () => { const exists1 = [false, true, false]; const exists2 = [false, true, false]; + const promises: Array> = []; + // Code blocks to run after each step. const run = [ () => { - doc1.set({foo: 'foo'}); - doc2.set({foo: 'foo'}); + promises.push(doc1.set({foo: 'foo'})); + promises.push(doc2.set({foo: 'foo'})); }, () => { - doc1.delete(); - doc2.delete(); + promises.push(doc1.delete()); + promises.push(doc2.delete()); }, () => { unsubscribe1(); unsubscribe2(); - done(); + Promise.all(promises).then(() => done()); }, ]; @@ -882,18 +891,20 @@ describe('DocumentReference class', () => { const exists1 = [false, true, false]; const exists2 = [false, true, false]; + const promises: Array> = []; + // Code blocks to run after each step. const run = [ () => { - doc.set({foo: 'foo'}); + promises.push(doc.set({foo: 'foo'})); }, () => { - doc.delete(); + promises.push(doc.delete()); }, () => { unsubscribe1(); unsubscribe2(); - done(); + Promise.all(promises).then(() => done()); }, ]; @@ -913,6 +924,37 @@ describe('DocumentReference class', () => { maybeRun(); }); }); + + it('handles more than 100 concurrent listeners', async () => { + const ref = randomCol.doc('doc'); + + const emptyResults: Array> = []; + const documentResults: Array> = []; + const unsubscribeCallbacks: Array<() => void> = []; + + // A single GAPIC client can only handle 100 concurrent streams. We set + // up 100+ long-lived listeners to verify that Firestore pools requests + // across multiple clients. + for (let i = 0; i < 150; ++i) { + emptyResults[i] = new Deferred(); + documentResults[i] = new Deferred(); + + unsubscribeCallbacks[i] = randomCol + .where('i', '>', i) + .onSnapshot(snapshot => { + if (snapshot.size === 0) { + emptyResults[i].resolve(); + } else if (snapshot.size === 1) { + documentResults[i].resolve(); + } + }); + } + + await Promise.all(emptyResults.map(d => d.promise)); + ref.set({i: 1337}); + await Promise.all(documentResults.map(d => d.promise)); + unsubscribeCallbacks.forEach(c => c()); + }); }); }); @@ -953,6 +995,8 @@ describe('Query class', () => { randomCol = getTestRoot(firestore); }); + afterEach(() => verifyInstance(firestore)); + it('has firestore property', () => { const ref = randomCol.limit(0); expect(ref.firestore).to.be.an.instanceOf(Firestore); @@ -1623,6 +1667,8 @@ describe('Transaction class', () => { randomCol = getTestRoot(firestore); }); + afterEach(() => verifyInstance(firestore)); + it('has get() method', () => { const ref = randomCol.doc('doc'); return ref @@ -1803,6 +1849,8 @@ describe('WriteBatch class', () => { randomCol = getTestRoot(firestore); }); + afterEach(() => verifyInstance(firestore)); + it('supports empty batches', () => { return firestore.batch().commit(); }); @@ -1912,6 +1960,8 @@ describe('QuerySnapshot class', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('has query property', () => { return querySnapshot .then(snapshot => { diff --git a/dev/test/collection.ts b/dev/test/collection.ts index eba915b14..450d196b7 100644 --- a/dev/test/collection.ts +++ b/dev/test/collection.ts @@ -23,6 +23,7 @@ import { DATABASE_ROOT, document, InvalidApiUsage, + verifyInstance, } from './util/helpers'; // Change the argument to 'console.log' to enable debug output. @@ -37,6 +38,8 @@ describe('Collection interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('has doc() method', () => { const collectionRef = firestore.collection('colId'); expect(collectionRef.doc); diff --git a/dev/test/document.ts b/dev/test/document.ts index dce473fc9..c00223e3b 100644 --- a/dev/test/document.ts +++ b/dev/test/document.ts @@ -42,6 +42,7 @@ import { stream, update, updateMask, + verifyInstance, writeResult, } from './util/helpers'; @@ -69,6 +70,8 @@ describe('DocumentReference interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('has collection() method', () => { expect(() => documentRef.collection(42 as InvalidApiUsage)).to.throw( 'Value for argument "collectionPath" is not a valid resource path. Path must be a non-empty string.' @@ -111,6 +114,8 @@ describe('serialize document', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('serializes to Protobuf JS', () => { const overrides: ApiOverride = { commit: (request, options, callback) => { @@ -680,6 +685,8 @@ describe('delete document', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('generates proto', () => { const overrides: ApiOverride = { commit: (request, options, callback) => { @@ -797,6 +804,8 @@ describe('set document', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('supports empty map', () => { const overrides: ApiOverride = { commit: (request, options, callback) => { @@ -1228,6 +1237,8 @@ describe('create document', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('creates document', () => { const overrides: ApiOverride = { commit: (request, options, callback) => { @@ -1350,6 +1361,8 @@ describe('update document', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('generates proto', () => { const overrides: ApiOverride = { commit: (request, options, callback) => { diff --git a/dev/test/index.ts b/dev/test/index.ts index c881967de..4530ae41f 100644 --- a/dev/test/index.ts +++ b/dev/test/index.ts @@ -33,6 +33,7 @@ import { InvalidApiUsage, missing, stream, + verifyInstance, } from './util/helpers'; import api = google.firestore.v1; @@ -512,6 +513,8 @@ describe('snapshot_() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('handles ProtobufJS', () => { const doc = firestore.snapshot_( document('doc', 'foo', {bytesValue: bytesData}), @@ -655,6 +658,8 @@ describe('doc() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('returns DocumentReference', () => { const documentRef = firestore.doc('collectionId/documentId'); expect(documentRef).to.be.an.instanceOf(Firestore.DocumentReference); @@ -694,6 +699,8 @@ describe('collection() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('returns collection', () => { const collection = firestore.collection('col1/doc1/col2'); expect(collection).to.be.an.instanceOf(Firestore.CollectionReference); diff --git a/dev/test/order.ts b/dev/test/order.ts index e81d787ea..f34d0b30f 100644 --- a/dev/test/order.ts +++ b/dev/test/order.ts @@ -28,7 +28,7 @@ import {GeoPoint} from '../src'; import {DocumentReference} from '../src'; import * as order from '../src/order'; import {QualifiedResourcePath} from '../src/path'; -import {createInstance, InvalidApiUsage} from './util/helpers'; +import {createInstance, InvalidApiUsage, verifyInstance} from './util/helpers'; import api = google.firestore.v1; @@ -44,6 +44,8 @@ describe('Order', () => { }); }); + afterEach(() => verifyInstance(firestore)); + /** Converts a value into its proto representation. */ function wrap(value: unknown): api.IValue { const val = firestore._serializer!.encodeValue(value); diff --git a/dev/test/pool.ts b/dev/test/pool.ts index a89abec5a..db027878d 100644 --- a/dev/test/pool.ts +++ b/dev/test/pool.ts @@ -18,7 +18,7 @@ import {expect, use} from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import {ClientPool} from '../src/pool'; -import {Deferred} from './util/helpers'; +import {Deferred} from '../src/util'; use(chaiAsPromised); diff --git a/dev/test/query.ts b/dev/test/query.ts index 07637e9e9..87fbc91e0 100644 --- a/dev/test/query.ts +++ b/dev/test/query.ts @@ -28,6 +28,7 @@ import { document, InvalidApiUsage, stream, + verifyInstance, } from './util/helpers'; import api = google.firestore.v1; @@ -292,6 +293,8 @@ describe('query interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('has isEqual() method', () => { const query = firestore.collection('collectionId'); @@ -624,6 +627,8 @@ describe('where() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('generates proto', () => { const overrides: ApiOverride = { runQuery: request => { @@ -904,6 +909,8 @@ describe('orderBy() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('accepts empty string', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1071,6 +1078,8 @@ describe('limit() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('generates proto', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1121,6 +1130,8 @@ describe('offset() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('generates proto', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1171,6 +1182,8 @@ describe('select() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('generates proto', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1240,6 +1253,8 @@ describe('startAt() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('accepts fields', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1622,6 +1637,8 @@ describe('startAfter() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('accepts fields', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1685,6 +1702,8 @@ describe('endAt() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('accepts fields', () => { const overrides: ApiOverride = { runQuery: request => { @@ -1744,6 +1763,8 @@ describe('endBefore() interface', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('accepts fields', () => { const overrides: ApiOverride = { runQuery: request => { diff --git a/dev/test/util/helpers.ts b/dev/test/util/helpers.ts index 0dfc5e21f..b04686aa7 100644 --- a/dev/test/util/helpers.ts +++ b/dev/test/util/helpers.ts @@ -42,25 +42,6 @@ export const DOCUMENT_NAME = `${COLLECTION_ROOT}/documentId`; // tslint:disable-next-line:no-any export type InvalidApiUsage = any; -/** A Promise implementation that supports deferred resolution. */ -export class Deferred { - promise: Promise; - resolve: (value?: R | Promise) => void = () => {}; - reject: (reason?: Error) => void = () => {}; - - constructor() { - this.promise = new Promise( - ( - resolve: (value?: R | Promise) => void, - reject: (reason?: Error) => void - ) => { - this.resolve = resolve; - this.reject = reject; - } - ); - } -} - /** * Interface that defines the request handlers used by Firestore. */ @@ -146,6 +127,21 @@ export function createInstance( return Promise.resolve(firestore); } +/** + * Verifies that all streams have been properly shutdown at the end of a test + * run. + */ +export function verifyInstance(firestore: Firestore): Promise { + // Allow the setTimeout() call in _initializeStream to run before + // verifying that all operations have finished executing. + return new Promise(resolve => { + setTimeout(() => { + expect(firestore['_clientPool'].opCount).to.equal(0); + resolve(); + }, 10); + }); +} + function write( document: api.IDocument | null, mask: api.IDocumentMask | null, diff --git a/dev/test/watch.ts b/dev/test/watch.ts index db126c45a..3b3207e51 100644 --- a/dev/test/watch.ts +++ b/dev/test/watch.ts @@ -44,7 +44,7 @@ import {DocumentSnapshotBuilder} from '../src/document'; import {DocumentChangeType} from '../src/document-change'; import {Serializer} from '../src/serializer'; import {GrpcError} from '../src/types'; -import {createInstance, InvalidApiUsage} from './util/helpers'; +import {createInstance, InvalidApiUsage, verifyInstance} from './util/helpers'; import api = google.firestore.v1; @@ -200,8 +200,8 @@ class DeferredListener { const listener = this.pendingListeners.shift(); if (listener) { - expect(listener.type).to.equal( - type, + expect(type).to.equal( + listener.type, `Expected message of type '${listener.type}' but got '${type}' ` + `with '${JSON.stringify(data)}'.` ); @@ -718,6 +718,7 @@ describe('Query watch', () => { afterEach(() => { setTimeoutHandler(setTimeout); + return verifyInstance(firestore); }); it('with invalid callbacks', () => { @@ -806,19 +807,15 @@ describe('Query watch', () => { watchHelper.sendAddTarget(); watchHelper.sendCurrent(); watchHelper.sendSnapshot(1, Buffer.from([0xabcd])); - return watchHelper - .await('snapshot') - .then(() => { - streamHelper.close(); - return streamHelper.awaitOpen(); - }) - .then(() => { - streamHelper.close(); - return streamHelper.awaitOpen(); - }) - .then(() => { - expect(streamHelper.streamCount).to.equal(3); - }); + return watchHelper.await('snapshot').then(async () => { + streamHelper.close(); + await streamHelper.awaitOpen(); + + streamHelper.close(); + await streamHelper.awaitOpen(); + + expect(streamHelper.streamCount).to.equal(3); + }); }); }); @@ -2242,6 +2239,7 @@ describe('DocumentReference watch', () => { afterEach(() => { setTimeoutHandler(setTimeout); + return verifyInstance(firestore); }); it('with invalid callbacks', () => { @@ -2586,6 +2584,8 @@ describe('Query comparator', () => { }); }); + afterEach(() => verifyInstance(firestore)); + function testSort( query: Query, input: QueryDocumentSnapshot[], diff --git a/dev/test/write-batch.ts b/dev/test/write-batch.ts index 194242bd6..da265c4ff 100644 --- a/dev/test/write-batch.ts +++ b/dev/test/write-batch.ts @@ -24,7 +24,12 @@ import { WriteBatch, WriteResult, } from '../src'; -import {ApiOverride, createInstance, InvalidApiUsage} from './util/helpers'; +import { + ApiOverride, + createInstance, + InvalidApiUsage, + verifyInstance, +} from './util/helpers'; const REQUEST_TIME = 'REQUEST_TIME'; @@ -44,6 +49,8 @@ describe('set() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('requires document name', () => { expect(() => (writeBatch as InvalidApiUsage).set()).to.throw( 'Value for argument "documentRef" is not a valid DocumentReference.' @@ -74,6 +81,8 @@ describe('delete() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('requires document name', () => { expect(() => (writeBatch as InvalidApiUsage).delete()).to.throw( 'Value for argument "documentRef" is not a valid DocumentReference.' @@ -98,6 +107,8 @@ describe('update() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('requires document name', () => { expect(() => writeBatch.update({} as InvalidApiUsage, {})).to.throw( 'Value for argument "documentRef" is not a valid DocumentReference.' @@ -132,6 +143,8 @@ describe('create() method', () => { }); }); + afterEach(() => verifyInstance(firestore)); + it('requires document name', () => { expect(() => (writeBatch as InvalidApiUsage).create()).to.throw( 'Value for argument "documentRef" is not a valid DocumentReference.' @@ -254,6 +267,8 @@ describe('batch support', () => { }); }); + afterEach(() => verifyInstance(firestore)); + function verifyResponse(writeResults: WriteResult[]) { expect(writeResults[0].writeTime.isEqual(new Timestamp(0, 0))).to.be.true; expect(writeResults[1].writeTime.isEqual(new Timestamp(1, 1))).to.be.true;